開発Tips

Firestoreをトランザクション化したら初回連携が壊れた話 — ルールが弾く read

家族向けアプリ「ぽんっ!」(旧称:でいりんぐ)は、こどものアカウントとおとなのアカウントを QR コードで連携して、離れて暮らす家族もスタンプを見守れるようにしています。あるとき、この おとな → こどもの初回連携が PERMISSION_DENIED で失敗するようになりました。以前は動いていたので、典型的な回帰(デグレ)です。原因の切り分けと、なぜそう直したかを残します。

背景(なぜ回帰を疑ったか)

「おとなからこどもへの初回連携ができない」という報告が来たとき、連携機能自体はずっと動いていたので、直感的に「どこかの変更で壊した回帰だ」と当たりをつけました。少し前に連携処理をトランザクション化していたので、まずそこを疑って追いかけたのが解決の糸口です。E2E は常に「連携済み」のテストアカウントで回していたため、この初回連携の経路だけがすり抜けていました。

症状

  • すでに連携済みの相手なら成功する。
  • 未連携のおとなが、はじめてこどもと連携しようとすると失敗する。
  • クライアントには Firestore の permission-denied が返る。

根本原因: トランザクションが「読めない相手」を read していた

連携を追記する処理は、途中のリファクタで トランザクション化され、先頭で相手(こども)のドキュメントを読み取っていました。ところがユーザードキュメントの read を許可するセキュリティルールは、おおむね「自分自身か、すでに連携済みの相手だけが読める」という条件でした。

つまり、これから連携しようとしている未連携のおとなは、相手のこどもをまだ読めません。トランザクションは最初の読み取りの時点で失敗していました。

リファクタ前は追記だけで read を伴っていなかったため成功していた——という、いかにも回帰らしい構図です。認可を要求する操作(read)を、認可が通らない文脈にうっかり足してしまったのが本質でした。

なぜユニットテストで気づけなかったか

ここが今回の大きな教訓でした。

  • モックの Firestore(fake_cloud_firestore)はセキュリティルールを評価しません。 そのため、ルール起因のこの不具合はユニットテストでは緑のまま通ってしまいます。
  • E2E は常に「連携済み」のテストアカウントを使っていました。未連携どうしの初回連携という経路をなぞっていなかったため、検知できませんでした。

修正: read せず両側を追記だけにする

修正はシンプルで、読み取りをやめることでした。なぜトランザクションをやめられたかというと、この処理に本当に必要だったのは「両側のリストに相手を追記する」ことだけで、事前の read は必須ではなかったからです。

  • 連携処理を、トランザクションではなく 両側のドキュメントへ集合追記(arrayUnion)する WriteBatch に変更。read が無ければルールにも抵触しません。
  • 連携数の上限(おとなは最大3人)は、おとな側が相手の連携相手数を知り得ないためクライアントでは判定できません。これは サーバ側の関数に担保させました。
  • 重複連携は集合追記の冪等性と、QR 読み取り時に自分の連携リストを事前チェックすることでカバーできます。

ルール起因の回帰は「ルールのテスト」で守る

同じ轍を踏まないために、セキュリティルール専用のテストを追加しました。

  • モックの Firestore ではなく、Firestore エミュレータ + @firebase/rules-unit-testing でルールそのものを検証する。
  • 「未連携どうしで初回連携できること」「連携済みでない相手を読めないこと」を明示的にテストする。

ユニットテスト(ロジック)とルールテスト(認可)は別レイヤーであり、片方だけでは守れないと実感した回帰でした。

まとめ

  • Firestore のセキュリティルールは read も write と同じくらい厳格に効く。トランザクション化で何気なく足した読み取りが認可を要求してしまう。
  • モックの Firestore はルールを評価しないため、認可の回帰はロジックのテストでは捕まえられない。
  • 認可に関わる変更は、エミュレータでルール自体をテストする。E2E のテストアカウントが「常に連携済み」のような特定状態に偏っていないかも見直す価値があります。