テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 前編】
IT技術
テスト駆動で書籍コレクションのルールを実装する
この記事では、前編・後編の二回に分けて、Firestore セキュリティルール中でのデータ比較について解説していきます。
書店のネットショッピングサービスを想定し、Firestore 上の書籍コレクションのルール実装を例に、解説を進めていきます。
前回のデータ検証編では、主にリクエストデータやデータベース上のデータの参照について解説しました。
今回の記事では、より具体的に、データの数値比較やデータ型のチェックについて解説していきます。
データ検証編の記事はこちら
簡単な流れ
「データ比較編」では、以下のような流れで解説を進めていきます。
データ比較編:前編
- 書籍コレクションの要件を設定
- テスト環境の準備
- テストデータの作成
- Firestore 上のデータ追加ルールの実装と解説
データ比較編:後編
- Firestore 上のデータ更新ルールの実装と解説
- Firestore 上のデータ取得ルールの実装と解説
- 全体テストで要件の最終チェック
- まとめ
今回は、前編パートの、「実装目標(要件)の設定からデータ追加ルールの実装まで」を解説していきます。
書籍コレクションの実装目標
書籍コレクション books を想定し、以下の要件を目標に実装を進めていきます。
書籍データ追加・更新の要件
- データ追加・更新をリクエストしたユーザが商品管理者でない場合は追加・更新不可
- データのサイズが「9」でない場合は追加・更新不可
- タイトルが string 型でない場合は追加・更新不可
- 書籍詳細が string 型でない場合は追加・更新不可
- 出版日が timestamp 型でない場合は追加・更新不可
- 価格が int 型で「0」以上でない場合は追加・更新不可
- 在庫が int 型で「0」以上でない場合は追加・更新不可
- 状態が new, used のいずれかでない場合は追加・更新不可
商品管理者に対する要件
ここで、商品管理者がデータ更新することを想定し、商品管理者に対する要件を以下のように補足します。
▼データ追加では、「リクエストデータの商品管理者にリクエストユーザが存在する」ものとします。
▼データ更新では、「データベース上のデータの商品管理者にリクエストユーザが存在する」ことを要件とします。
データの取得要件
- 下書きデータの取得不可
- 在庫が「5」以下の書籍は取得不可
在庫が「5」以下の場合は、店頭販売のみを行うと仮定し、取得不可としています。
前準備 ~ テスト環境 ~
初めに、テスト環境を準備します。
テスト環境のセットアップ
以下のコマンドを実行して、テスト環境をセットアップしてください。
1git clone "https://github.com/rightcode/firestore-security-rules-test_data-compare.git" sandbox
2cd sandbox
3git checkout refs/tags/test-environment
なお、今回の記事のコードは、以下のリポジトリにまとめてあります。
【GitHub】
rightcode/firestore-security-rules-test_data-compare
npm パッケージのインストール
以下のコマンドを実行して、必要となる npm パッケージをインストールしてください。
1npm install
Firebase CLI ツールのインストール
まだ、Firebase CLI ツールをインストールしていない場合は、以下のコマンドを実行してインストールします。
1npm install -g firebase-tools
Firestore エミュレータを起動
そして、コマンドを実行して、Firestore エミュレータを起動してください。
1firebase setup:emulators:firestore
2firebase emulators:start --only firestore
以上で、テスト環境の準備は完了です!
テスト環境のセットアップの解説
なお、テスト環境のセットアップについて、より詳細な内容は、以下の記事をお読みください!
コードをチェックアウトする
今回の記事で解説するコードを確認する場合は、以下のコマンドより「master ブランチ」をチェックアウトしてください。
1git checkout master
「master ブランチ」に実装済みのコードを設置してあります。
※注意※
コードの調整などにより、この記事の内容とは、記述が一部異なる場合があります。
前準備 ~ テストデータ ~
テストを作成する前に、まず、テストデータ用のコードを用意します。
tests/data.ts ファイルを作成して、以下のように記述してください。
テストデータのコード
1// コレクションパス
2export const collectionPath = "books";
3
4// 商品管理者
5export const adminUser = "tanaka";
6
7// 商品ID
8export const itemId = "XXXXXXXXXX";
9
10// 発売日
11const releaseDate = new Date('2000-01-01 00:00:00');
12
13// 初期データ
14export const initialData = {
15 id: itemId,
16 title: "銀河鉄道の朝",
17 description: "人間か機械か、勝つのはどっちだ!",
18 releaseDate: releaseDate,
19 price: 2500,
20 stock: 6,
21 condition: "new",
22 adminUsers: [adminUser, "yamada"],
23 draft: false
24};
25
26// 更新用データ
27export const validUpdateData = {
28 id: itemId,
29 title: "北風と太平洋",
30 description: "北風と太平洋、勝つのはどっちだ!",
31 releaseDate: releaseDate,
32 price: 30000,
33 stock: 6,
34 condition: "used",
35 adminUsers: [adminUser, "yamada"],
36 draft: false
37};
テストデータの概要
adminUser
テストで対象となる、書籍データの商品管理者の1人として定義。
データの追加・更新が可能なユーザとして使用します。
initialData
初期データ。
データ追加テストでのデータ追加と、更新・取得テスト前の初期データの準備に使用します。
validUpdateData
更新テスト用データ。
要件に沿ったデータで、データの更新に使用します。
Firestore 上のデータ追加ルールの実装
データ追加の要件
データ追加ルールのテストを作成する前に、まずは、データ追加の要件を再確認します。
- データ追加をリクエストしたユーザが商品管理者でない場合は追加不可
- データのサイズが「9」でない場合は追加不可
- タイトルが string 型でない場合は追加不可
- 書籍詳細が string 型でない場合は追加不可
- 出版日が timestamp 型でない場合は追加不可
- 価格が int 型で「0」以上でない場合は追加不可
- 在庫が int 型で「0」以上でない場合は追加不可
- 状態が new, used のいずれかでない場合は追加不可
ここで、一番上の商品管理者に関する要件については、「リクエストデータの商品管理者にリクエストユーザが存在する」ことが要件となります。
テストの作成
上記の要件に沿って、テストを作成します。
tests/data.add.test.ts ファイルを追加して、以下のようにコードを記述してください。
テストのコード
1// Firestoreエミュレータのホストとポートを指定
2process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080";
3
4import {FirestoreTestSupporter} from "firestore-test-supporter";
5
6import * as path from "path";
7import * as firebase from "@firebase/testing";
8
9// テストデータの読み込み
10import {collectionPath, adminUser, itemId, initialData} from "./data";
11
12describe("書籍データの追加テスト", () => {
13 const supporter = new FirestoreTestSupporter("my-test-project", path.join(__dirname, "firestore.rules"));
14
15 beforeEach(async () => {
16 // セキュリティルールの読み込み
17 await supporter.loadRules();
18 });
19
20 afterEach(async () => {
21 // データのクリーンアップ
22 await supporter.cleanup()
23 });
24
25 test('要件にあったデータの追加に成功', async () => {
26 const db = supporter.getFirestoreWithAuth(adminUser);
27 const doc = db.collection(collectionPath).doc(itemId);
28 await firebase.assertSucceeds(doc.set(initialData))
29 });
30
31 test('データ追加をリクエストしたユーザが商品管理者でない場合は追加不可', async () => {
32 // 非商品管理者で認証されたクライアントを取得
33 const db = supporter.getFirestoreWithAuth("non_admin_user");
34
35 const doc = db.collection(collectionPath).doc(itemId);
36 await firebase.assertFails(doc.set(initialData))
37 });
38
39 test('データのサイズが9でない場合は追加不可', async () => {
40 const db = supporter.getFirestoreWithAuth(adminUser);
41 const doc = db.collection(collectionPath).doc(itemId);
42
43 // データサイズが追加対象外であるデータを作成
44 const badData = {...initialData, author: "Hosoda"};
45
46 await firebase.assertFails(doc.set(badData))
47 });
48
49 test('タイトルがstring型でない場合は追加不可', async () => {
50 const db = supporter.getFirestoreWithAuth(adminUser);
51 const doc = db.collection(collectionPath).doc(itemId);
52
53 // タイトルの型が追加対象外であるデータを作成
54 const badData = {...initialData, title: 5};
55
56 await firebase.assertFails(doc.set(badData))
57 });
58
59 test('書籍詳細がstring型でない場合は追加不可', async () => {
60 const db = supporter.getFirestoreWithAuth(adminUser);
61 const doc = db.collection(collectionPath).doc(itemId);
62
63 // 書籍詳細の型が追加対象外であるデータを作成
64 const badData = {...initialData, description: true};
65
66 await firebase.assertFails(doc.set(badData))
67 });
68
69 test('出版日がtimestamp型でない場合は追加不可', async () => {
70 const db = supporter.getFirestoreWithAuth(adminUser);
71 const doc = db.collection(collectionPath).doc(itemId);
72
73 // 出版日の型が追加対象外であるデータを作成
74 const badData = {...initialData, releaseDate: "yesterday"};
75
76 await firebase.assertFails(doc.set(badData))
77 });
78
79 test('価格がint型で0以上でない場合は追加不可', async () => {
80 const db = supporter.getFirestoreWithAuth(adminUser);
81 const doc = db.collection(collectionPath).doc(itemId);
82
83 // 価格の型が追加対象外であるデータを作成
84 const badData1 = {...initialData, price: "二千"};
85
86 await firebase.assertFails(doc.set(badData1));
87
88 // 価格の値が追加対象外であるデータを作成
89 const badData2 = {...initialData, price: -1};
90
91 await firebase.assertFails(doc.set(badData2))
92 });
93
94 test('在庫がint型で0以上でない場合は追加不可', async () => {
95 const db = supporter.getFirestoreWithAuth(adminUser);
96 const doc = db.collection(collectionPath).doc(itemId);
97
98 // 在庫数の型が追加対象外であるデータを作成
99 const badData1 = {...initialData, stock: "4"};
100
101 await firebase.assertFails(doc.set(badData1));
102
103 // 在庫数の値が追加対象外であるデータを作成
104 const badData2 = {...initialData, stock: -1};
105
106 await firebase.assertFails(doc.set(badData2))
107 });
108
109 test('状態がnew, usedのいずれかでない場合は追加不可', async () => {
110 const db = supporter.getFirestoreWithAuth(adminUser);
111 const doc = db.collection(collectionPath).doc(itemId);
112
113 // 状態の値が追加対象外であるデータを作成
114 const badData = {...initialData, condition: "old"};
115
116 await firebase.assertFails(doc.set(badData))
117 });
118});
テストの概要
「データ追加をリクエストしたユーザが商品管理者でない場合は追加不可」となるテスト
商品管理者に含まれないユーザとして、non_admin_user で認証したクライアントを取得しています。
「データのサイズが「9」でない場合は追加不可」となるテスト
リクエストデータのサイズを変更するため、初期データに author フィールドを追加しています。
型のチェックテスト
各テストのデータ追加リクエストの前に、初期データを調整して、不適切な型の値を設定しています。
ルールの実装
データ追加ルール用のテストが完成したので、このテストに沿ってセキュリティルールを実装していきます。
テスト駆動のスタイルに従い、まずはテストを実行してみて、テストが失敗することを確認します。
全てのアクセスを許可するセキュリティルールを設定
今回は、少しだけルールの実装を進めて、create アクセスを全て許可するところからスタートします。
books コレクションの create アクセスを全て許可し、「要件にあったデータの追加に成功」するテストだけを通します。
その上で、失敗した追加不可テストを上から順に通しながらルールを実装していきます。
tests/firestore.rules を追加して、以下のように記述してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /books/{id}{
5 allow create: if true;
6 }
7 }
8}
テストをスタート
ファイルを追加したら、以下のコマンドを実行して、テストをスタートしてください。
1npm run test-watch tests/data.add.test.ts
テスト結果
テスト結果は、以下のようになります。(テスト結果の冒頭のみを抜粋)
1 FAIL tests/data.add.test.ts
2 商品追加テスト
3 √ 要件にあったデータの追加に成功 (2089ms)
4 × データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (55ms)
5 × データのサイズが9でない場合は追加不可 (39ms)
6 × タイトルがstring型でない場合は追加不可 (39ms)
7 × 書籍詳細がstring型でない場合は追加不可 (43ms)
8 × 出版日がtimestamp型でない場合は追加不可 (38ms)
9 × 価格がint型で0以上でない場合は追加不可 (33ms)
10 × 在庫がint型で0以上でない場合は追加不可 (36ms)
11 × 状態がnew, usedのいずれかでない場合は追加不可 (46ms)
「要件にあったデータの追加に成功」するテストだけが通りました。
前述の通り、失敗した追加不可テストを上から順に通して行きます。
リスト型データ中の指定値の有無を条件に使用する
「データ追加をリクエストしたユーザが、商品管理者でない場合は追加不可」となるよう、以下のルールを設定してください。
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 }
7 }
8}
ここで、request.auth.uid in request.resource.data.adminUsers の条件により、リクエストデータの商品管理者リストの中に、リクエストユーザが含まれるかをチェックしています。
リスト型のデータに、in 演算子を使用することで、そのリストに指定の値が存在するかをチェックできます。
テスト結果
ルールの変更により、テストが再度実行され、以下のような結果が表示されます!
1 FAIL tests/data.add.test.ts (5.147s)
2 商品追加テスト
3 √ 要件にあったデータの追加に成功 (2909ms)
4 √ データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (93ms)
5 × データのサイズが9でない場合は追加不可 (63ms)
6 × タイトルがstring型でない場合は追加不可 (61ms)
7 × 書籍詳細がstring型でない場合は追加不可 (62ms)
8 × 出版日がtimestamp型でない場合は追加不可 (45ms)
9 × 価格がint型で0以上でない場合は追加不可 (43ms)
10 × 在庫がint型で0以上でない場合は追加不可 (44ms)
11 × 状態がnew, usedのいずれかでない場合は追加不可 (49ms)
データの要素数の比較結果を条件に使用する
次に、「データのサイズが「9」でない場合は追加不可」となるテストを通すため、以下のようにルールを追加してください。
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 }
8 }
9}
request.resource.data.size() == 9 の条件により、リクエストデータの要素の数が「9」である場合のみ、追加アクセスを許可しています。
マップ型やリスト型のデータには、size() メソッドが用意されており、データの要素数を参照することができます。
テスト結果
1 商品追加テスト
2 √ 要件にあったデータの追加に成功 (2108ms)
3 √ データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (49ms)
4 √ データのサイズが9でない場合は追加不可 (44ms)
5 × タイトルがstring型でない場合は追加不可 (56ms)
6 × 書籍詳細がstring型でない場合は追加不可 (42ms)
7 × 出版日がtimestamp型でない場合は追加不可 (43ms)
8 × 価格がint型で0以上でない場合は追加不可 (40ms)
9 × 在庫がint型で0以上でない場合は追加不可 (37ms)
10 × 状態がnew, usedのいずれかでない場合は追加不可 (38ms)
「データのサイズが9でない場合は追加不可」となるテストが通りました。
データ型や数値の比較結果を条件に使用する
最後に、「残っているテスト」と「型と値のチェックテスト」をまとめて通してしまいます。
以下のように、ルールを調整しましょう!
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 }
17}
ここで、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 PASS tests/data.add.test.ts
2 商品追加テスト
3 √ 要件にあったデータの追加に成功 (2111ms)
4 √ データ追加をリクエストしたユーザが商品管理者でない場合は追加不可 (49ms)
5 √ データのサイズが9でない場合は追加不可 (39ms)
6 √ タイトルがstring型でない場合は追加不可 (50ms)
7 √ 書籍詳細がstring型でない場合は追加不可 (41ms)
8 √ 出版日がtimestamp型でない場合は追加不可 (38ms)
9 √ 価格がint型で0以上でない場合は追加不可 (46ms)
10 √ 在庫がint型で0以上でない場合は追加不可 (53ms)
11 √ 状態がnew, usedのいずれかでない場合は追加不可 (41ms)
全てのテストが通りました!
以上で、書籍データ追加用のルールは、実装完了です!
最後に、次のテストとの競合を避けるため、テストを終了してください。
後編へつづく!
今回の記事では、「実装目標(要件)の設定」から「データ追加ルールの実装」までを、テスト駆動のスタイルで解説してみました。
後編では、前編の内容を復習しつつ、前編と同様に、テスト駆動スタイルで Firestore 上のデータ更新と取得のルールを実装していきます。
後編はこちら
こちらの記事もオススメ!
2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
2020.08.04エンジニアの働き方 特集社員としての働き方社員としてのエンジニアの働き方とは?ライトコードのエンジニアはどんな働き方をしてるのか、まとめたいと...
2020.07.27IT・コンピューターの歴史特集IT・コンピューターの歴史をまとめていきたいと思います!弊社ブログにある記事のみで構成しているため、まだ「未完成状態」...
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「好きを仕事にするエンジニア集団」の(株)ライトコードです! ライトコードは、福岡、東京、大阪の3拠点で事業展開するIT企業です。 現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。 いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。 システム開発依頼・お見積もり大歓迎! また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」「WEBディレクター」を積極採用中です! インターンや新卒採用も行っております。 以下よりご応募をお待ちしております! https://rightcode.co.jp/recruit
おすすめ記事
浮動小数点について調べてみた
2024.09.09