【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. 参考

関連記事

  1. flutter

    【Flutter】Firebase Dynamic Linksを使う

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

  3. TypeScript

    【TypeScript】Vitestを使ってみる

  4. flutter

    【Flutter】Cloud Firestoreと連携

  5. TypeScript

    【TypeScript】TypeScriptで変数の型を宣言する

  6. Firebase

    【Firebase】Cloud Functionsを使ってみる

最近の記事

  1. AWS
  2. AWS
  3. AWS
  4. AWS
  5. AWS
  6. AWS
  7. AWS
  8. AWS

制作実績一覧

  1. Checkeys