
テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 後編】
2021.12.20
テスト駆動で書籍コレクションのルールを実装する【後編】
この記事では、前編・後編の二回を通して、Firestore セキュリティルール中でのデータ比較について解説しています。
前編では、書店のネットショッピングサービスを想定した Firestore 上の書籍コレクションを例に、要件の設定からデータ追加のルールの実装までを解説しました。
後編では、「データ更新と取得のルールの実装」を進めていきます。
前回の記事はこちら
「データ比較編」の前編については、以下の記事をご覧ください。
Firestore 上のデータ更新ルールの実装
それでは、まず、データ更新用ルールの実装を進めていきます。
データ追加ルールの実装と同様、最初に、更新ルール用のテストを作成します。
データ更新の要件
テストを作成する前に、データ更新の要件を再確認しましょう!
- データ更新をリクエストしたユーザが商品管理者でない場合は更新不可
- データのサイズが「9」でない場合は更新不可
- タイトルが string 型でない場合は更新不可
- 書籍詳細が string 型でない場合は更新不可
- 出版日が timestamp 型でない場合は更新不可
- 価格が int 型で「0」以上でない場合は更新不可
- 在庫が int 型で「0」以上でない場合は更新不可
- 状態が new, used のいずれかでない場合は更新不可
ここで、一番上の商品管理者に関する要件については、データ更新の場合、「データベース上のデータの商品管理者にリクエストユーザが存在する」ことが要件となります。
テストの作成
上記の要件に沿って、テストを作成します。
tests/data.update.test.ts ファイルを追加して、以下のようにコードを記述してみましょう!
テストのコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080"; import {FirestoreTestSupporter} from "firestore-test-supporter"; import * as path from "path"; import * as firebase from "@firebase/testing"; import {collectionPath, adminUser, itemId, initialData, validUpdateData} from "./data"; describe("書籍データの更新テスト", () => { const supporter = new FirestoreTestSupporter("my-test-project", path.join(__dirname, "firestore.rules")); beforeEach(async () => { await supporter.loadRules(); // 初期データを追加 const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertSucceeds(doc.set(initialData)) }); afterEach(async () => { await supporter.cleanup() }); test('要件にあったデータの更新に成功', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertSucceeds(doc.update(validUpdateData)) }); test('データ更新をリクエストしたユーザが商品管理者でない場合は更新不可', async () => { // 商品管理者を変更するリクエストデータを用意 const tunedValidUpdateData = {...validUpdateData, adminUsers: ["new_admin_user"]}; // データベース上のデータの商品管理者にリクエストユーザがいない場合の更新不可チェック const db1 = supporter.getFirestoreWithAuth("new_admin_user"); const doc1 = db1.collection(collectionPath).doc(itemId); await firebase.assertFails(doc1.update(tunedValidUpdateData)) // データベース上のデータの商品管理者にリクエストユーザがいる場合の商品管理者の更新成功チェック const db2 = supporter.getFirestoreWithAuth(adminUser); const doc2 = db2.collection(collectionPath).doc(itemId); await firebase.assertSucceeds(doc2.update(tunedValidUpdateData)) }); test('データのサイズが9でない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData = {...validUpdateData, author: "Hosoda"}; await firebase.assertFails(doc.update(badData)) }); test('タイトルがstring型でない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData = {...validUpdateData, title: 5}; await firebase.assertFails(doc.update(badData)) }); test('書籍詳細がstring型でない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData = {...validUpdateData, description: true}; await firebase.assertFails(doc.update(badData)) }); test('出版日がtimestamp型でない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData = {...validUpdateData, releaseDate: "yesterday"}; await firebase.assertFails(doc.update(badData)) }); test('価格がint型で0以上でない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData1 = {...validUpdateData, price: "二千"}; await firebase.assertFails(doc.update(badData1)); const badData2 = {...validUpdateData, price: -1}; await firebase.assertFails(doc.update(badData2)) }); test('在庫がint型で0以上でない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData1 = {...validUpdateData, stock: "4"}; await firebase.assertFails(doc.update(badData1)); const badData2 = {...validUpdateData, stock: -1}; await firebase.assertFails(doc.update(badData2)) }); test('状態がnew, usedのいずれかでない場合は更新不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); const badData = {...validUpdateData, condition: "old"}; await firebase.assertFails(doc.update(badData)) }); }); |
テストの概要
データ更新の要件は、データ追加の要件と同様の設定であるため、テストの内容も近いものとなっています。
テスト内容の主な違いは、以下の3つです。
- beforeEach() 内で初期データを準備していること
- 各テストでデータ更新メソッド update() を使用していること
- 「データ更新をリクエストしたユーザが商品管理者でない場合は更新不可」となるテストの内容(後述)
「データ更新をリクエストしたユーザが商品管理者でない場合は更新不可」となるテスト
データ更新については、「データベース上のデータの商品管理者にリクエストユーザが存在する」ことをアクセス許可の要件として設定しています。
商品管理者の更新を想定して、リクエストデータ中の商品管理者にリクエストユーザがいなくても、データの更新ができるように要件を設定しています。
この点は、データ追加テストの場合とは異なる点です。
リクエストユーザを含むようにデータを調整
また、リクエストデータの商品管理者に、リクエストユーザを含むようにデータを調整しています。
これにより、調整されたリクエストデータでリクエストに失敗した場合は、データベース上のデータの商品管理者に、リクエストユーザがいなかったから、「リクエストに失敗した」と判定できます。
さらに、テストの主目的である「データ更新をリクエストしたユーザが商品管理者でない場合は更新不可」となることのチェックも実施。
加えて、データベース上の商品管理者にリクエストユーザがいる場合は、リクエストデータの商品管理者にリクエストユーザがいなくても、データ更新に成功することも確認しています。
これにより、既存の商品管理者であれば、商品管理者を更新できることを担保しています。
ルールの実装
テストの開始前に、「要件にあったデータの更新に成功」するテストを通すため、以下のようにルールを調整しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /books/{id}{ allow create: if request.auth.uid in request.resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; allow update: if true; } } } |
テストをスタート
以下のコマンドを実行して、テストをスタートしてください!
1 | npm run test-watch tests/data.update.test.ts |
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.update.test.ts 商品更新テスト √ 要件にあったデータの更新に成功 (2150ms) × データ更新をリクエストしたユーザが商品管理者でない場合は更新不可 (85ms) × データのサイズが8でない場合は更新不可 (75ms) × タイトルがstring型でない場合は更新不可 (71ms) × 書籍詳細がstring型でない場合は更新不可 (73ms) × 出版日がtimestamp型でない場合は更新不可 (62ms) × 価格がint型で0以上でない場合は更新不可 (70ms) × 在庫がint型で0以上でない場合は更新不可 (71ms) × 状態がnew, usedのいずれかでない場合は更新不可 (63ms) |
データ更新の要件をセキュリティルールに設定
データ更新の要件は、データ追加の要件とほぼ同じなので、 create アクセスに対するルールを参考に、 update アクセス用のルールをまとめて追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /books/{id}{ allow create: if request.auth.uid in request.resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; allow update: if request.auth.uid in resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; } } } |
create に対するルールと update に対するルールは、ほぼ同じです。
商品管理者をチェックしている部分は異なる
ですが、商品管理者をチェックしている部分は異なります。
create アクセスのルールでは、リクエストデータの商品管理者にリクエストユーザが含まれている場合、アクセスを許可しています。
それに対して、 updateアクセスのルールでは、データベース上のデータの商品管理者にリクエストユーザが含まれている場合、アクセスを許可しています。
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | PASS tests/data.update.test.ts (5.692s) 商品更新テスト √ 要件にあったデータの更新に成功 (2151ms) √ データ更新をリクエストしたユーザが商品管理者でない場合は更新不可 (94ms) √ データのサイズが8でない場合は更新不可 (73ms) √ タイトルがstring型でない場合は更新不可 (69ms) √ 書籍詳細がstring型でない場合は更新不可 (72ms) √ 出版日がtimestamp型でない場合は更新不可 (70ms) √ 価格がint型で0以上でない場合は更新不可 (69ms) √ 在庫がint型で0以上でない場合は更新不可 (72ms) √ 状態がnew, usedのいずれかでない場合は更新不可 (2131ms) |
全てのテストが通ったので、書籍データ更新用のルールの実装は、これで完了です。
次のテストとの競合を避けるため、テストを終了してください。
Firestore 上のデータ取得ルールの実装
続いて、データ取得用のルールを実装していきましょう。
データ取得の要件
データの追加・更新の場合と同様、まずは、テスト作成の前にデータ取得の要件を再確認します。
- 下書きデータの取得不可
- 在庫が「5」以下の書籍の取得不可
テストの作成
上記の要件に沿って、テストを作成します。
tests/data.get.test.ts ファイルを追加して、以下のようにコードを記述します。
テストのコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080"; import { FirestoreTestSupporter } from "firestore-test-supporter"; import * as path from "path"; import * as firebase from "@firebase/testing"; import { collectionPath, adminUser, itemId, initialData, validUpdateData } from "./data"; describe("書籍データの取得テスト", () => { const supporter = new FirestoreTestSupporter("my-test-project", path.join(__dirname, "firestore.rules")); beforeEach(async () => { await supporter.loadRules(); const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertSucceeds(doc.set(initialData)) }); afterEach(async () => { await supporter.cleanup() }); test('要件にあったデータの取得に成功', async () => { // 認証なしクライアントの取得 const db = supporter.getFirestore(); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertSucceeds(doc.get()) }); test('下書きデータの取得不可', async () => { // テスト対象データの下書きフラグをtrueに変更 const dbWithAdmin = supporter.getFirestoreWithAuth(adminUser); const docWithAdmin = dbWithAdmin.collection(collectionPath).doc(itemId); const newData = { ...validUpdateData, draft: true }; await firebase.assertSucceeds(docWithAdmin.set(newData)); const db = supporter.getFirestore(); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertFails(doc.get()) }); test('在庫が5以下の書籍の取得不可', async () => { // テスト対象データの在庫数を取得対象外の値に変更 const dbWithAdmin = supporter.getFirestoreWithAuth(adminUser); const docWithAdmin = dbWithAdmin.collection(collectionPath).doc(itemId); const newData = { ...validUpdateData, stock: 5 }; await firebase.assertSucceeds(docWithAdmin.set(newData)); const db = supporter.getFirestore(); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertFails(doc.get()) }); }); |
テストの概要
そして、「下書きデータの取得不可」となるテストでは、テスト前にデータベース上のデータの draft フィールドを、 true に変更して、テスト失敗用のデータを用意しています。
同様に、「在庫が「5」以下の書籍の取得不可」となるテストでは、取得対象外となる在庫数が「5」のデータを用意しています。
ルールの実装
では、テストに沿って、データ取得用のルールを実装していきます。
まずは、「要件にあったデータの取得に成功」するようにルールを調整します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /books/{id}{ allow create: if request.auth.uid in request.resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; allow update: if request.auth.uid in resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; allow get: if true; } } } |
テスト結果
1 2 3 4 5 | FAIL tests/data.get.test.ts 商品取得テスト √ 要件にあったデータの取得に成功 (2150ms) × 下書きデータの取得不可 (97ms) × 在庫が5以下の書籍の取得不可 (120ms) |
データ取得の要件をセキュリティルールに設定
ここまでの内容の復習となりますので、「失敗しているテスト」をまとめて通します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /books/{id}{ allow create: if request.auth.uid in request.resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; allow update: if request.auth.uid in resource.data.adminUsers && request.resource.data.size() == 9 && request.resource.data.title is string && request.resource.data.description is string && request.resource.data.releaseDate is timestamp && request.resource.data.price is int && request.resource.data.price >= 0 && request.resource.data.stock is int && request.resource.data.stock >= 0 && request.resource.data.condition in ["new","used"]; allow get: if resource.data.draft != true && resource.data.stock > 5; } } } |
resource.data.draft != true の条件により、下書きデータの取得を不可としています。
また、 resource.data.stock > 5 の条件により、在庫数が「5」以下の書籍データの取得を不可としています。
テスト結果
1 2 3 4 5 | PASS tests/data.get.test.ts 商品取得テスト √ 要件にあったデータの取得に成功 (2137ms) √ 下書きデータの取得不可 (78ms) √ 在庫が5以下の書籍の取得不可 (107ms) |
全てのテストが成功しましたので、書籍データの取得ルールの実装は、これで完了です。
次のテストとの競合を避けるために、テストを終了してください。
リグレッションテスト
最後に、ルールの調整を進める中で、ルールが要件から外れてしまっていないかを確認します。
全てのテストを実行
以下のコマンドを実行して、全てのテストを実行しましょう!
1 | npm run test |
テスト結果
以下のような結果が表示されれば、テストは成功です!
1 2 3 4 5 6 7 8 9 | PASS tests/data.update.test.ts PASS tests/data.add.test.ts PASS tests/data.get.test.ts Test Suites: 3 passed, 3 total Tests: 21 passed, 21 total Snapshots: 0 total Time: 7.418s, estimated 8s Ran all test suites. |
全てのテストに成功しました!
以上で、前編冒頭で設定した「要件に沿ったルール」を実装することができました。
テスト駆動で書籍コレクションのルールを実装 データ比較後編のまとめ
以上、書店のネットショッピングサービスを例に、「Firestore 上の書籍コレクションのルールを実装」するとともに、セキュリティルール中での「データ比較」について解説してみました。
最後に、今回の内容を簡単にまとめてみたいと思います。
- in 演算子で値の存在チェック。マップ型ならキーの存在チェック。
- size() メソッドでデータの要素数チェック。文字列型なら文字数チェック。
- is 演算子で型チェック。
- 数値型は >= や < などの演算子で数値比較可。
- 締めのリグレッションテスト!
型や型ごとに使えるメソッドは、今回紹介したものの他にも多数用意されています。
より詳細な内容は、以下のページを参照してみてください!
【Firebase】
Namespace: rules | Firebase
こちらの記事もオススメ!
データ検証編はこちら
「データ検証編」では、リクエストデータ、データベース上のデータの参照などについて解説
データ比較編はこちら
「データ比較編」では、データの比較や型チェックなどについて解説
カスタム関数編はこちら
「カスタム関数編」では、「比較編」で作成したルールをもとに、カスタム関数の使い方を解説
書いた人はこんな人

- 「好きを仕事にするエンジニア集団」の(株)ライトコードです!
ライトコードは、福岡、東京、大阪の3拠点で事業展開するIT企業です。
現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。
いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。
システム開発依頼・お見積もり大歓迎!
また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」「WEBディレクター」を積極採用中です!
インターンや新卒採用も行っております。
以下よりご応募をお待ちしております!
https://rightcode.co.jp/recruit
ITエンタメ10月 13, 2023Netflixの成功はレコメンドエンジン?
ライトコードの日常8月 30, 2023退職者の最終出社日に密着してみた!
ITエンタメ8月 3, 2023世界初の量産型ポータブルコンピュータを開発したのに倒産!?アダム・オズボーン
ITエンタメ7月 14, 2023【クリス・ワンストラス】GitHubが出来るまでとソフトウェアの未来