本文是 Firestore Hands-on 操作路線 的 lab,實作 Security Rules 授權建模 deep article 的測試方法。前置環境見 Local emulator quickstart。測試 API 以 Rules unit testing 文件 為準、最後檢查日 2026-06-16。

Firestore Security Rules test lab 的核心責任是把授權規則變成可自動驗證的測試。規則是 client 直連模型的整個控制面,改一條就要證明沒開新洞——這個 lab 用 @firebase/rules-unit-testing 在 emulator 上對規則跑斷言,產出可接進 CI 與 release gate 的測試 evidence。

本文的驗收標準是:你能對一組規則寫出「放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕」四類斷言、用 firebase emulators:exec 一鍵跑完、並看到 assertFails 確實證明該擋的有擋住。

Lab 環境與依賴

沿用 quickstart 的工作區與 firebase.json / firestore.rules。再裝測試依賴。

1cd /tmp/firestore-lab
2npm install --save-dev @firebase/rules-unit-testing firebase jest

驗收前置是 firestore.rules 存在(quickstart 已建立 owner-scoped 規則)與 firebase.json 宣告了 Firestore emulator。

升級規則:加入欄位竄改防護

quickstart 的規則擋了越權讀寫,但還沒擋「owner 改自己 note 時偷改 ownerId 把資料轉走」。先把規則升級到帶欄位白名單,讓測試有更多面向可驗。

 1cat > firestore.rules <<'RULES'
 2rules_version = '2';
 3service cloud.firestore {
 4  match /databases/{database}/documents {
 5
 6    function isSignedIn() { return request.auth != null; }
 7
 8    function ownsExisting() {
 9      return isSignedIn() && resource.data.ownerId == request.auth.uid;
10    }
11
12    function onlyChanges(fields) {
13      return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
14    }
15
16    match /notes/{noteId} {
17      allow read: if ownsExisting();
18      allow create: if isSignedIn()
19                    && request.resource.data.ownerId == request.auth.uid;
20      allow update: if ownsExisting() && onlyChanges(['text', 'updatedAt']);
21      allow delete: if ownsExisting();
22    }
23  }
24}
25RULES

onlyChanges(['text', 'updatedAt']) 是這版的重點:update 只准動 textupdatedAt,碰 ownerId 直接拒絕。下面的測試會驗證它。

寫測試:四類斷言

測試的核心責任是覆蓋「該放行的放行、該拒絕的拒絕」。initializeTestEnvironment 載入規則、authenticatedContext 模擬登入身分、assertSucceeds / assertFails 對操作斷言。預先種資料用 withSecurityRulesDisabled 繞過規則。

 1cat > rules.test.js <<'JS'
 2const {
 3  initializeTestEnvironment, assertFails, assertSucceeds,
 4} = require('@firebase/rules-unit-testing');
 5const { doc, getDoc, setDoc, updateDoc } = require('firebase/firestore');
 6const fs = require('fs');
 7
 8let testEnv;
 9
10beforeAll(async () => {
11  testEnv = await initializeTestEnvironment({
12    projectId: 'demo-firestore-lab',
13    firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') },
14  });
15});
16afterAll(async () => { await testEnv.cleanup(); });
17beforeEach(async () => {
18  await testEnv.clearFirestore();
19  await testEnv.withSecurityRulesDisabled(async (ctx) => {
20    await setDoc(doc(ctx.firestore(), 'notes/n1'),
21      { ownerId: 'alice', text: 'hi', updatedAt: 0 });
22  });
23});
24
25// 1. 放行:owner 讀自己的
26test('owner reads own note', async () => {
27  const db = testEnv.authenticatedContext('alice').firestore();
28  await assertSucceeds(getDoc(doc(db, 'notes/n1')));
29});
30
31// 2. 越權拒絕:非 owner 讀別人的
32test('non-owner cannot read', async () => {
33  const db = testEnv.authenticatedContext('bob').firestore();
34  await assertFails(getDoc(doc(db, 'notes/n1')));
35});
36
37// 3. 未登入拒絕
38test('unauthenticated denied', async () => {
39  const db = testEnv.unauthenticatedContext().firestore();
40  await assertFails(getDoc(doc(db, 'notes/n1')));
41});
42
43// 4. 欄位竄改拒絕:owner 偷改 ownerId
44test('owner cannot change ownerId', async () => {
45  const db = testEnv.authenticatedContext('alice').firestore();
46  await assertFails(updateDoc(doc(db, 'notes/n1'), { ownerId: 'bob' }));
47});
48
49// 4b. 正當 update 放行
50test('owner can edit text', async () => {
51  const db = testEnv.authenticatedContext('alice').firestore();
52  await assertSucceeds(updateDoc(doc(db, 'notes/n1'), { text: 'edited', updatedAt: 1 }));
53});
54JS

四類斷言裡 assertFailsassertSucceeds 更重要——它證明的是攻擊路徑被擋住,正是滲透測試會打的點。每條規則至少要有「正向放行 + 至少一條拒絕」配對,光測 happy path 證明不了授權安全。

一鍵跑:emulators:exec

跑測試的核心責任是讓它在乾淨 emulator 上自動化執行。firebase emulators:exec 啟動 emulator、跑指定命令、結束後關閉——適合 CI,不需要手動開關 emulator。

1cat > package.json.test <<'JSON'
2{ "scripts": { "test:rules": "jest rules.test.js" } }
3JSON
4# 把 test:rules script 併進既有 package.json 後執行:
5
6firebase emulators:exec --only firestore --project demo-firestore-lab "npx jest rules.test.js"

預期輸出五個測試全 pass:

1PASS  ./rules.test.js
2  owner reads own note (passed)
3  non-owner cannot read (passed)
4  unauthenticated denied (passed)
5  owner cannot change ownerId (passed)
6  owner can edit text (passed)
7
8Test Suites: 1 passed, 1 total
9Tests:       5 passed, 5 total

(Jest 預設 reporter 每行會印一個通過標記、此處以 (passed) 文字呈現,實際終端輸出為工具自身格式。)

故意改壞驗證測試有效

測試的價值在於它會抓到回歸。把規則改回 allow read, write: if true 再跑,應看到「越權拒絕」「未登入拒絕」「欄位竄改拒絕」三個測試 fail——這證明測試確實守在攻擊路徑上,而不是恆綠的假測試。

1# 暫時把規則改成全放行
2printf "rules_version='2';\nservice cloud.firestore{match /databases/{db}/documents{match /{d=**}{allow read,write:if true;}}}" > firestore.rules
3firebase emulators:exec --only firestore --project demo-firestore-lab "npx jest rules.test.js"
4# 預期:3 個 assertFails 測試 fail(該擋的沒擋)
5# 驗證完改回上面的正確規則

Artifact 與驗收

Artifact來源驗收
規則測試檔rules.test.js四類斷言 + 正向 update
測試結果emulators:exec 輸出正確規則下全 pass
回歸證明改壞後重跑3 個 assertFails 測試轉 fail

接進 release gate

規則測試的下游責任是成為發布證據。把 firebase emulators:exec ... jest 接進 CI pipeline,規則變更的 PR 必須通過才能 merge——這把「規則改動沒開新洞」從人工推敲變成 gate 條件,對齊 6.8 release gateGate decision / Checks / Stop condition。授權翻譯的正確性是安全邊界,這個 gate 比一般功能測試更該設為硬性 stop condition。

Cleanup

1# emulators:exec 跑完會自動關 emulator;清依賴與工作區
2rm -rf /tmp/firestore-lab

引用路徑