【Firebase】Firestore Security Rulesを書いてVitestで単体テスト

Firebase

1. 概要

Cloud Firestoreのセキュリティルールを書いてVitestで単体テストをしてみます。

  • ディレクトリ&ファイル構造
tree -L 2
.
├── firebase.json
├── firestore-debug.log
├── firestore.indexes.json
├── firestore.rules
├── functions
│   ├── firestore-debug.log
│   ├── lib
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   ├── src
│   ├── tests
│   ├── tsconfig.json
│   ├── ui-debug.log
│   └── vite.config.ts
├── storage.rules
└── ui-debug.log

5 directories, 12 files

  • データ構造
{
  users: {
    {userId}: {
      "userId": string,
      "name": string,
      "createdAt": timestamp,
      bookings: {
        {bookingId}: {
          "place": string,
          "price": int,
          "createdAt": timestamp
        }
      }
    }
  }
}

2. プロジェクトを作成

こちらを参考

3. Firestoreのセットアップ

こちらを参考

4. プロジェクトのロケーションを変更

こちらを参考

5. Functionsのセットアップ

こちらを参考

6. TypeScriptのコンパイラをインストール

こちらを参考

7. tsconfig.json を生成 + 修正

こちらを参考

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2017",
    "types": [
      "vitest/globals"
    ]
  },
  "compileOnSave": true,
  "include": [
    "src/**/*",
    "tests/**/*"
  ]
}

8. Vitestを導入する

こちらを参考

9. 必要モジュールのインストール

9-1. Firestoreテスト用モジュール

npm install -D @firebase/rules-unit-testing

9-2. UUID

npm install -D @types/uuid

10. エミュレータ スイートのセットアップ

10-1. 初期化

10-2. functions/package.json

{
  "name": "functions",
  "scripts": {
    "lint": "eslint --ext .js,.ts .",
    "build": "tsc",
    "build:watch": "tsc --watch",
    "serve": "npm run build && firebase emulators:start",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log",
    "test": "vitest",
    "test:rules": "firebase emulators:exec 'vitest'"
  },
  "engines": {
    "node": "18"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^11.5.0",
    "firebase-functions": "^4.2.0"
  },
  "devDependencies": {
    "@firebase/rules-unit-testing": "^2.0.7",
    "@types/uuid": "^9.0.0",
    "@typescript-eslint/eslint-plugin": "^5.48.2",
    "@typescript-eslint/parser": "^5.48.2",
    "eslint": "^8.32.0",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-import": "^2.27.5",
    "firebase-functions-test": "^3.0.0",
    "typescript": "^4.9.4",
    "vite": "^4.1.2",
    "vitest": "^0.28.5"
  },
  "private": true
}

11. ソースコード

11-1. firestore.rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userId) {
      return isAuthenticated() && request.auth.uid == userId;
    }

    function isValidUserData(userData) {
      return userData.size() == 3
             && 'userId' in userData && userData.userId is string
             && 'name' in userData && userData.name is string
             && 'createdAt' in userData && userData.createdAt is timestamp && userData.createdAt == request.time;
    }

    function isValidCreateBookingData(bookingData) {
      return bookingData.size() == 3
             && 'place' in bookingData && bookingData.place is string
             && 'price' in bookingData && bookingData.price is int
             && 'createdAt' in bookingData && bookingData.createdAt is timestamp && bookingData.createdAt == request.time
             && bookingData.price > 1;
    }

    function isValidUpdateBookingData(bookingData) {
      return 'place' in bookingData && bookingData.place is string
             && 'price' in bookingData && bookingData.price is int
             && bookingData.price > 1;
    }

    match /users/{userId} {
      allow get: if isUserAuthenticated(userId);
      allow create: if isUserAuthenticated(userId) && isValidUserData(request.resource.data) && request.resource.data.userId == userId;
      allow update: if isUserAuthenticated(userId) && 'name' in request.resource.data && request.resource.data.userId == userId;
      allow delete: if false;

      match /bookings/{bookingId} {
        allow read: if isUserAuthenticated(userId);
        allow create: if isUserAuthenticated(userId) && isValidCreateBookingData(request.resource.data);
        allow update: if isUserAuthenticated(userId) && isValidUpdateBookingData(request.resource.data);
        allow delete: if isUserAuthenticated(userId);
      }
    }
  }
}

11-2. functions/tests/test-init-env.ts

import { initializeTestEnvironment, RulesTestEnvironment } from '@firebase/rules-unit-testing';
import { readFileSync } from 'fs';

let rulesTestEnv: RulesTestEnvironment;

export const initTestEnv = async(projectId: string) => {
    rulesTestEnv = await initializeTestEnvironment({
        projectId: projectId,
        firestore: {
            rules: readFileSync('../firestore.rules', 'utf8'),
            host: 'localhost',
            port: 8080
        }
    });
}

export const getRulesTestEnv = () => rulesTestEnv;

11-3. functions/tests/test-setup.ts

import { TokenOptions } from '@firebase/rules-unit-testing';
import { initTestEnv, getRulesTestEnv } from './test-init-env';

export const setupTest = (projectId: string) => {
    beforeAll(async() => {
        await initTestEnv(projectId);
    });
    
    afterAll(async() => {
        getRulesTestEnv().cleanup();
    });
}

export const getFirestore = (authUser?: { uid: string; token?: TokenOptions }) => authUser ? getRulesTestEnv().authenticatedContext(authUser.uid, authUser.token) : getRulesTestEnv().unauthenticatedContext();

export const getTestEnv = () => getRulesTestEnv();

11-4. functions/tests/test-utils.ts

import { serverTimestamp } from 'firebase/firestore';

export const requestTime = () => serverTimestamp();

11-5. functions/tests/sample.test.ts

import { setupTest, getFirestore, getTestEnv } from './test-setup';
import { requestTime } from './test-utils';
import { v4 as uuidv4 } from 'uuid';
import * as testing from '@firebase/rules-unit-testing';

const projectId = uuidv4();
setupTest(projectId);

let authenticated: testing.RulesTestContext;
let unauthenticated: testing.RulesTestContext;

const collectionId = 'users';
const userId = uuidv4();
const userData = {
    userId: userId,
    name: 'Sondon',
    createdAt: requestTime()
}

describe('User tasks', () => {
    beforeEach(async() => {
        await getTestEnv().withSecurityRulesDisabled(context => {
            return context.firestore().collection(collectionId).doc(userId).set(userData);
        });
        authenticated = getFirestore({uid: userId});
        unauthenticated = getFirestore();
    });
    afterEach(async() => {
        getTestEnv().clearFirestore();
    });
    test('Unauthenticated user cannot read', async () => {
        await testing.assertFails(unauthenticated.firestore().collection(collectionId).doc(userId).get());
    });
    test('Unauthenticated user cannot create', async () => {
        await testing.assertFails(unauthenticated.firestore().collection(collectionId).add(userData));
    });
    test('Only authenticated user can create', async() => {
        await testing.assertSucceeds(authenticated.firestore().collection(collectionId).doc(userId).set(userData));
    });
    test('Only authenticated user can update', async() => {
        await testing.assertSucceeds(authenticated.firestore().collection(collectionId).doc(userId).update(userData))
    });
    test('Even authenticated user cannot delete', async() => {
        await Promise.all([
            testing.assertFails(authenticated.firestore().collection(collectionId).doc(userId).delete())
        ]);
    });
});

const subCollectionId = 'bookings';
const bookingId = uuidv4();
const bookingData = {
    place: 'Kyoto',
    price: 30000,
    createdAt: requestTime()
}

describe('Booking tasks', () => {
    beforeEach(async() => {
        await getTestEnv().withSecurityRulesDisabled(context => {
            return context.firestore().collection(collectionId).doc(userId).collection(subCollectionId).doc(bookingId).set(bookingData);
        });
        authenticated = getFirestore({uid: userId});
        unauthenticated = getFirestore();
    });
    afterEach(async() => {
        getTestEnv().clearFirestore();
    });
    test('Unauthenticated user cannot read', async () => {
        await testing.assertFails(unauthenticated.firestore().collection(collectionId).doc(userId).collection(subCollectionId).doc(bookingId).get());
    });
    test('Unauthenticated user cannot create', async () => {
        await testing.assertFails(unauthenticated.firestore().collection(collectionId).doc(userId).collection(subCollectionId).add(bookingData));
    });
    test('Only authenticated user can create', async() => {
        await testing.assertSucceeds(authenticated.firestore().collection(collectionId).doc(userId).collection(subCollectionId).add(bookingData));
    });
    test('Only authenticated user can update', async() => {
        await testing.assertSucceeds(authenticated.firestore().collection(collectionId).doc(userId).collection(subCollectionId).doc(bookingId).update({place: 'Tokyo', price: 50000}));
    });
    test('Only authenticated user cannot delete', async() => {
        await Promise.all([
            testing.assertSucceeds(authenticated.firestore().collection(collectionId).doc(userId).collection(subCollectionId).doc(bookingId).delete())
        ]);
    });
});

12. テスト実行

12-1. 手動

12-1-1. ターミナル1(エミュレーターの起動)

firebase emulators:start

12-1-2. ターミナル2(テスト実行)

npm run test

> test
> vitest


 DEV  v0.28.5 /home/sondon/dev/wsl/gcp/firebase/sample003/functions

 ✓ tests/sample.test.ts (10) 979ms

 Test Files  1 passed (1)
      Tests  10 passed (10)
   Start at  21:42:37
   Duration  1.67s (transform 104ms, setup 0ms, collect 259ms, tests 979ms)


 PASS  Waiting for file changes...
       press h to show help, press q to quit

12-2. 自動

12-2-1. ターミナル(エミュレーターの起動+テスト実行)

npm run test:rules

13. 備考

セキュリティルールで下記の制御ができる事は開発者にはやさしいと思いますね。

  • 認証
  • スキーマ検証
  • データのバリデーション

更にローカルでテストできる事もとても助かりますね。

14. 参考

投稿者プロフィール

Sondon
開発好きなシステムエンジニアです。
卓球にハマってます。

関連記事

  1. Firebase

    【Firebase】Cloud Firestoreデータ抽出

  2. Firebase

    【Firebase】CloudFirestoreトリガーを使ってみる

  3. TypeScript

    【TypeScript】インターフェイスの実装

  4. 【TypeScript】filterを使って配列からundefined…

  5. TypeScript

    【TypeScript】ループ時の非同期処理の処理順序(async/a…

  6. 【React×TypeScript】環境構築メモ

最近の記事

  1. raspberrypi

制作実績一覧

  1. Checkeys