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. 参考
- CloudFirestoreのセキュリティルールを始めましょう | Firebase (google.com)
- Firebaseセキュリティルールの使用を開始する | Firebase セキュリティ ルール (google.com)
- 単体テストを作成する | Firebase セキュリティ ルール (google.com)
- RulesTestEnvironmentインターフェイス | Firebase (google.com)
投稿者プロフィール
-
開発好きなシステムエンジニアです。
卓球にハマってます。