• トップ
  • ブログ一覧
  • テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 後編】
  • テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 後編】

    広告メディア事業部広告メディア事業部
    2020.05.07

    IT技術

    テスト駆動で書籍コレクションのルールを実装する【後編】

    この記事では、前編・後編の二回を通して、Firestore セキュリティルール中でのデータ比較について解説しています。

    前編では、書店のネットショッピングサービスを想定した Firestore 上の書籍コレクションを例に、要件の設定からデータ追加のルールの実装までを解説しました。

    後編では、「データ更新と取得のルールの実装」を進めていきます。

    前回の記事はこちら

    「データ比較編」の前編については、以下の記事をご覧ください。

    featureImg2020.05.07テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 前編】テスト駆動で書籍コレクションのルールを実装するこの記事では、前編・後編の二回に分けて、Firestore セキュリティ...

    Firestore 上のデータ更新ルールの実装

    それでは、まず、データ更新用ルールの実装を進めていきます。

    データ追加ルールの実装と同様、最初に、更新ルール用のテストを作成します。

    データ更新の要件

    テストを作成する前に、データ更新の要件を再確認しましょう!

    1. データ更新をリクエストしたユーザが商品管理者でない場合は更新不可
    2. データのサイズが「9」でない場合は更新不可
    3. タイトルが string 型でない場合は更新不可
    4. 書籍詳細が string 型でない場合は更新不可
    5. 出版日が timestamp 型でない場合は更新不可
    6. 価格が int 型で「0」以上でない場合は更新不可
    7. 在庫が int 型で「0」以上でない場合は更新不可
    8. 状態が 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つです。

    1. beforeEach() 内で初期データを準備していること
    2. 各テストでデータ更新メソッド update() を使用していること
    3. 「データ更新をリクエストしたユーザが商品管理者でない場合は更新不可」となるテストの内容(後述)

    「データ更新をリクエストしたユーザが商品管理者でない場合は更新不可」となるテスト

    データ更新については、「データベース上のデータの商品管理者にリクエストユーザが存在する」ことをアクセス許可の要件として設定しています。

    商品管理者の更新を想定して、リクエストデータ中の商品管理者にリクエストユーザがいなくても、データの更新ができるように要件を設定しています。

    この点は、データ追加テストの場合とは異なる点です。

    リクエストユーザを含むようにデータを調整

    また、リクエストデータの商品管理者に、リクエストユーザを含むようにデータを調整しています。

    これにより、調整されたリクエストデータでリクエストに失敗した場合は、データベース上のデータの商品管理者に、リクエストユーザがいなかったから、「リクエストに失敗した」と判定できます。

    さらに、テストの主目的である「データ更新をリクエストしたユーザが商品管理者でない場合は更新不可」となることのチェックも実施。

    加えて、データベース上の商品管理者にリクエストユーザがいる場合は、リクエストデータの商品管理者にリクエストユーザがいなくても、データ更新に成功することも確認しています。

    これにより、既存の商品管理者であれば、商品管理者を更新できることを担保しています。

    ルールの実装

    テストの開始前に、「要件にあったデータの更新に成功」するテストを通すため、以下のようにルールを調整しましょう。

    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 上のデータ取得ルールの実装

    続いて、データ取得用のルールを実装していきましょう。

    データ取得の要件

    データの追加・更新の場合と同様、まずは、テスト作成の前にデータ取得の要件を再確認します。

    1. 下書きデータの取得不可
    2. 在庫が「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 上の書籍コレクションのルールを実装」するとともに、セキュリティルール中での「データ比較」について解説してみました。

    最後に、今回の内容を簡単にまとめてみたいと思います。

    1. in 演算子で値の存在チェック。マップ型ならキーの存在チェック。
    2. size() メソッドでデータの要素数チェック。文字列型なら文字数チェック。
    3. is 演算子で型チェック。
    4. 数値型は >= や < などの演算子で数値比較可。
    5. 締めのリグレッションテスト!

    型や型ごとに使えるメソッドは、今回紹介したものの他にも多数用意されています。

    より詳細な内容は、以下のページを参照してみてください!

    【Firebase】
    Namespace: rules | Firebase

    こちらの記事もオススメ!

    featureImg2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...

    featureImg2020.08.04エンジニアの働き方 特集社員としての働き方社員としてのエンジニアの働き方とは?ライトコードのエンジニアはどんな働き方をしてるのか、まとめたいと...

    featureImg2020.07.27IT・コンピューターの歴史特集IT・コンピューターの歴史をまとめていきたいと思います!弊社ブログにある記事のみで構成しているため、まだ「未完成状態」...

    データ検証編はこちら

    「データ検証編」では、リクエストデータ、データベース上のデータの参照などについて解説

    featureImg2020.04.22テスト駆動で学ぶ Firestore セキュリティルール 【データ検証編 / 前編】テスト駆動で商品データのルールを実装するこの記事では、セキュリティルールによるデータ検証について、商品データのルールを...

    データ比較編はこちら

    「データ比較編」では、データの比較や型チェックなどについて解説

    featureImg2020.05.07テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 前編】テスト駆動で書籍コレクションのルールを実装するこの記事では、前編・後編の二回に分けて、Firestore セキュリティ...

    カスタム関数編はこちら

    「カスタム関数編」では、「比較編」で作成したルールをもとに、カスタム関数の使い方を解説

    featureImg2020.06.04テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第1回】テスト駆動で成績データコレクションのルールを実装する【第1回】この記事では、全4回に分けて、Firestore セキュ...

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    広告メディア事業部

    広告メディア事業部

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background