
テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 前編】
2021.12.20
テスト駆動で書籍コレクションのルールを実装する
この記事では、前編・後編の二回に分けて、Firestore セキュリティルール中でのデータ比較について解説していきます。
書店のネットショッピングサービスを想定し、Firestore 上の書籍コレクションのルール実装を例に、解説を進めていきます。
前回のデータ検証編では、主にリクエストデータやデータベース上のデータの参照について解説しました。
今回の記事では、より具体的に、データの数値比較やデータ型のチェックについて解説していきます。
データ検証編の記事はこちら
簡単な流れ
「データ比較編」では、以下のような流れで解説を進めていきます。
データ比較編:前編
- 書籍コレクションの要件を設定
- テスト環境の準備
- テストデータの作成
- Firestore 上のデータ追加ルールの実装と解説
データ比較編:後編
- Firestore 上のデータ更新ルールの実装と解説
- Firestore 上のデータ取得ルールの実装と解説
- 全体テストで要件の最終チェック
- まとめ
今回は、前編パートの、「実装目標(要件)の設定からデータ追加ルールの実装まで」を解説していきます。
書籍コレクションの実装目標
書籍コレクション books を想定し、以下の要件を目標に実装を進めていきます。
書籍データ追加・更新の要件
- データ追加・更新をリクエストしたユーザが商品管理者でない場合は追加・更新不可
- データのサイズが「9」でない場合は追加・更新不可
- タイトルが string 型でない場合は追加・更新不可
- 書籍詳細が string 型でない場合は追加・更新不可
- 出版日が timestamp 型でない場合は追加・更新不可
- 価格が int 型で「0」以上でない場合は追加・更新不可
- 在庫が int 型で「0」以上でない場合は追加・更新不可
- 状態が new, used のいずれかでない場合は追加・更新不可
商品管理者に対する要件
ここで、商品管理者がデータ更新することを想定し、商品管理者に対する要件を以下のように補足します。
▼データ追加では、「リクエストデータの商品管理者にリクエストユーザが存在する」ものとします。
▼データ更新では、「データベース上のデータの商品管理者にリクエストユーザが存在する」ことを要件とします。
データの取得要件
- 下書きデータの取得不可
- 在庫が「5」以下の書籍は取得不可
在庫が「5」以下の場合は、店頭販売のみを行うと仮定し、取得不可としています。
前準備 ~ テスト環境 ~
初めに、テスト環境を準備します。
テスト環境のセットアップ
以下のコマンドを実行して、テスト環境をセットアップしてください。
1 2 3 | git clone "https://github.com/rightcode/firestore-security-rules-test_data-compare.git" sandbox cd sandbox git checkout refs/tags/test-environment |
なお、今回の記事のコードは、以下のリポジトリにまとめてあります。
【GitHub】
rightcode/firestore-security-rules-test_data-compare
npm パッケージのインストール
以下のコマンドを実行して、必要となる npm パッケージをインストールしてください。
1 | npm install |
Firebase CLI ツールのインストール
まだ、Firebase CLI ツールをインストールしていない場合は、以下のコマンドを実行してインストールします。
1 | npm install -g firebase-tools |
Firestore エミュレータを起動
そして、コマンドを実行して、Firestore エミュレータを起動してください。
1 2 | firebase setup:emulators:firestore firebase emulators:start --only firestore |
以上で、テスト環境の準備は完了です!
テスト環境のセットアップの解説
なお、テスト環境のセットアップについて、より詳細な内容は、以下の記事をお読みください!
コードをチェックアウトする
今回の記事で解説するコードを確認する場合は、以下のコマンドより「master ブランチ」をチェックアウトしてください。
1 | git checkout master |
「master ブランチ」に実装済みのコードを設置してあります。
※注意※
コードの調整などにより、この記事の内容とは、記述が一部異なる場合があります。
前準備 ~ テストデータ ~
テストを作成する前に、まず、テストデータ用のコードを用意します。
tests/data.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 | // コレクションパス export const collectionPath = "books"; // 商品管理者 export const adminUser = "tanaka"; // 商品ID export const itemId = "XXXXXXXXXX"; // 発売日 const releaseDate = new Date('2000-01-01 00:00:00'); // 初期データ export const initialData = { id: itemId, title: "銀河鉄道の朝", description: "人間か機械か、勝つのはどっちだ!", releaseDate: releaseDate, price: 2500, stock: 6, condition: "new", adminUsers: [adminUser, "yamada"], draft: false }; // 更新用データ export const validUpdateData = { id: itemId, title: "北風と太平洋", description: "北風と太平洋、勝つのはどっちだ!", releaseDate: releaseDate, price: 30000, stock: 6, condition: "used", adminUsers: [adminUser, "yamada"], draft: false }; |
テストデータの概要
adminUser
テストで対象となる、書籍データの商品管理者の1人として定義。
データの追加・更新が可能なユーザとして使用します。
initialData
初期データ。
データ追加テストでのデータ追加と、更新・取得テスト前の初期データの準備に使用します。
validUpdateData
更新テスト用データ。
要件に沿ったデータで、データの更新に使用します。
Firestore 上のデータ追加ルールの実装
データ追加の要件
データ追加ルールのテストを作成する前に、まずは、データ追加の要件を再確認します。
- データ追加をリクエストしたユーザが商品管理者でない場合は追加不可
- データのサイズが「9」でない場合は追加不可
- タイトルが string 型でない場合は追加不可
- 書籍詳細が string 型でない場合は追加不可
- 出版日が timestamp 型でない場合は追加不可
- 価格が int 型で「0」以上でない場合は追加不可
- 在庫が int 型で「0」以上でない場合は追加不可
- 状態が new, used のいずれかでない場合は追加不可
ここで、一番上の商品管理者に関する要件については、「リクエストデータの商品管理者にリクエストユーザが存在する」ことが要件となります。
テストの作成
上記の要件に沿って、テストを作成します。
tests/data.add.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 111 112 113 114 115 116 117 118 | // Firestoreエミュレータのホストとポートを指定 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} from "./data"; describe("書籍データの追加テスト", () => { const supporter = new FirestoreTestSupporter("my-test-project", path.join(__dirname, "firestore.rules")); beforeEach(async () => { // セキュリティルールの読み込み await supporter.loadRules(); }); afterEach(async () => { // データのクリーンアップ await supporter.cleanup() }); test('要件にあったデータの追加に成功', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertSucceeds(doc.set(initialData)) }); test('データ追加をリクエストしたユーザが商品管理者でない場合は追加不可', async () => { // 非商品管理者で認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth("non_admin_user"); const doc = db.collection(collectionPath).doc(itemId); await firebase.assertFails(doc.set(initialData)) }); test('データのサイズが9でない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // データサイズが追加対象外であるデータを作成 const badData = {...initialData, author: "Hosoda"}; await firebase.assertFails(doc.set(badData)) }); test('タイトルがstring型でない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // タイトルの型が追加対象外であるデータを作成 const badData = {...initialData, title: 5}; await firebase.assertFails(doc.set(badData)) }); test('書籍詳細がstring型でない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // 書籍詳細の型が追加対象外であるデータを作成 const badData = {...initialData, description: true}; await firebase.assertFails(doc.set(badData)) }); test('出版日がtimestamp型でない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // 出版日の型が追加対象外であるデータを作成 const badData = {...initialData, releaseDate: "yesterday"}; await firebase.assertFails(doc.set(badData)) }); test('価格がint型で0以上でない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // 価格の型が追加対象外であるデータを作成 const badData1 = {...initialData, price: "二千"}; await firebase.assertFails(doc.set(badData1)); // 価格の値が追加対象外であるデータを作成 const badData2 = {...initialData, price: -1}; await firebase.assertFails(doc.set(badData2)) }); test('在庫がint型で0以上でない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // 在庫数の型が追加対象外であるデータを作成 const badData1 = {...initialData, stock: "4"}; await firebase.assertFails(doc.set(badData1)); // 在庫数の値が追加対象外であるデータを作成 const badData2 = {...initialData, stock: -1}; await firebase.assertFails(doc.set(badData2)) }); test('状態がnew, usedのいずれかでない場合は追加不可', async () => { const db = supporter.getFirestoreWithAuth(adminUser); const doc = db.collection(collectionPath).doc(itemId); // 状態の値が追加対象外であるデータを作成 const badData = {...initialData, condition: "old"}; await firebase.assertFails(doc.set(badData)) }); }); |
テストの概要
「データ追加をリクエストしたユーザが商品管理者でない場合は追加不可」となるテスト
商品管理者に含まれないユーザとして、 non_admin_user で認証したクライアントを取得しています。
「データのサイズが「9」でない場合は追加不可」となるテスト
リクエストデータのサイズを変更するため、初期データに author フィールドを追加しています。
型のチェックテスト
各テストのデータ追加リクエストの前に、初期データを調整して、不適切な型の値を設定しています。
ルールの実装
データ追加ルール用のテストが完成したので、このテストに沿ってセキュリティルールを実装していきます。
テスト駆動のスタイルに従い、まずはテストを実行してみて、テストが失敗することを確認します。
全ての create アクセスを許可するセキュリティルールを設定
今回は、少しだけルールの実装を進めて、 create アクセスを全て許可するところからスタートします。
books コレクションの create アクセスを全て許可し、「要件にあったデータの追加に成功」するテストだけを通します。
その上で、失敗した追加不可テストを上から順に通しながらルールを実装していきます。
tests/firestore.rules を追加して、以下のように記述してください。
1 2 3 4 5 6 7 8 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /books/{id}{ allow create: if true; } } } |
テストをスタート
ファイルを追加したら、以下のコマンドを実行して、テストをスタートしてください。
1 | npm run test-watch tests/data.add.test.ts |
テスト結果
テスト結果は、以下のようになります。(テスト結果の冒頭のみを抜粋)
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.add.test.ts 商品追加テスト √ 要件にあったデータの追加に成功 (2089ms) × データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (55ms) × データのサイズが9でない場合は追加不可 (39ms) × タイトルがstring型でない場合は追加不可 (39ms) × 書籍詳細がstring型でない場合は追加不可 (43ms) × 出版日がtimestamp型でない場合は追加不可 (38ms) × 価格がint型で0以上でない場合は追加不可 (33ms) × 在庫がint型で0以上でない場合は追加不可 (36ms) × 状態がnew, usedのいずれかでない場合は追加不可 (46ms) |
「要件にあったデータの追加に成功」するテストだけが通りました。
前述の通り、失敗した追加不可テストを上から順に通して行きます。
リスト型データ中の指定値の有無を条件に使用する
「データ追加をリクエストしたユーザが、商品管理者でない場合は追加不可」となるよう、以下のルールを設定してください。
1 2 3 4 5 6 7 8 | 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.auth.uid in request.resource.data.adminUsers の条件により、リクエストデータの商品管理者リストの中に、リクエストユーザが含まれるかをチェックしています。
リスト型のデータに、 in 演算子を使用することで、そのリストに指定の値が存在するかをチェックできます。
テスト結果
ルールの変更により、テストが再度実行され、以下のような結果が表示されます!
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.add.test.ts (5.147s) 商品追加テスト √ 要件にあったデータの追加に成功 (2909ms) √ データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (93ms) × データのサイズが9でない場合は追加不可 (63ms) × タイトルがstring型でない場合は追加不可 (61ms) × 書籍詳細がstring型でない場合は追加不可 (62ms) × 出版日がtimestamp型でない場合は追加不可 (45ms) × 価格がint型で0以上でない場合は追加不可 (43ms) × 在庫がint型で0以上でない場合は追加不可 (44ms) × 状態がnew, usedのいずれかでない場合は追加不可 (49ms) |
データの要素数の比較結果を条件に使用する
次に、「データのサイズが「9」でない場合は追加不可」となるテストを通すため、以下のようにルールを追加してください。
1 2 3 4 5 6 7 8 9 | 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.size() == 9 の条件により、リクエストデータの要素の数が「9」である場合のみ、追加アクセスを許可しています。
マップ型やリスト型のデータには、 size() メソッドが用意されており、データの要素数を参照することができます。
テスト結果
1 2 3 4 5 6 7 8 9 10 | 商品追加テスト √ 要件にあったデータの追加に成功 (2108ms) √ データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (49ms) √ データのサイズが9でない場合は追加不可 (44ms) × タイトルがstring型でない場合は追加不可 (56ms) × 書籍詳細がstring型でない場合は追加不可 (42ms) × 出版日がtimestamp型でない場合は追加不可 (43ms) × 価格がint型で0以上でない場合は追加不可 (40ms) × 在庫がint型で0以上でない場合は追加不可 (37ms) × 状態がnew, usedのいずれかでない場合は追加不可 (38ms) |
「データのサイズが9でない場合は追加不可」となるテストが通りました。
データ型や数値の比較結果を条件に使用する
最後に、「残っているテスト」と「型と値のチェックテスト」をまとめて通してしまいます。
以下のように、ルールを調整しましょう!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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"]; } } } |
ここで、 is 演算子により、各フィールドの型を指定しています。
例えば、 releaseDate フィールドは、 request.resource.data.releaseDate is timestamp の条件により、タイムスタンプ型のみが許可されています。
int 型などの数値型は、 >= や < などの演算子を使って、数値の比較ができます。
例えば、 request.resource.data.price >= 0 の条件によって、 price フィールドは、「0」以上でない場合はデータを追加できません。
先ほど説明した in 演算子を使って、 request.resource.data.condition in ["new","used"] の条件により、 condition フィールドの値は "new" か "used" に制限しています。
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | PASS tests/data.add.test.ts 商品追加テスト √ 要件にあったデータの追加に成功 (2111ms) √ データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (49ms) √ データのサイズが9でない場合は追加不可 (39ms) √ タイトルがstring型でない場合は追加不可 (50ms) √ 書籍詳細がstring型でない場合は追加不可 (41ms) √ 出版日がtimestamp型でない場合は追加不可 (38ms) √ 価格がint型で0以上でない場合は追加不可 (46ms) √ 在庫がint型で0以上でない場合は追加不可 (53ms) √ 状態がnew, usedのいずれかでない場合は追加不可 (41ms) |
全てのテストが通りました!
以上で、書籍データ追加用のルールは、実装完了です!
最後に、次のテストとの競合を避けるため、テストを終了してください。
後編へつづく!
今回の記事では、「実装目標(要件)の設定」から「データ追加ルールの実装」までを、テスト駆動のスタイルで解説してみました。
後編では、前編の内容を復習しつつ、前編と同様に、テスト駆動スタイルで Firestore 上のデータ更新と取得のルールを実装していきます。
後編はこちら
こちらの記事もオススメ!
書いた人はこんな人

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