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