Chapter 13 · Advanced Security Rules, Query Compatibility, App Check, and Rules Testing

Testing Rules in CI with Emulators, Fixtures, Authentication Claims, and Regression Cases

Turn AtlasMart Security Rules into a deterministic CI contract with fixtures, mock claims, forged requests, coverage evidence, and controlled insecure-regression injection.

Intermediate → Advanced165–195 minutesEmulator CI · regression testingFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Build deterministic CI rules tests with isolated fixtures, mock authentication/custom claims, positive and adversarial cases.

02

Generate and retain Firestore emulator Rules coverage evidence without confusing expression coverage with threat coverage.

03

Demonstrate a deliberately insecure rule regression that makes CI fail, then restore the safe rule and verify recovery.

04

Separate emulator-verifiable security behavior from App Check enforcement, IAM, production indexes, billing and observability.

Execution and safety note

Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.

Chapter 13 reproducibility baseline · reviewed 16 September 2026

AtlasMart continues the same mandatory environment used in Chapters 01–12: project ID demo-atlasmart-firestore, Standard edition / Native mode / (default) database, Firestore emulator 127.0.0.1:8080, Authentication emulator 127.0.0.1:9099, Emulator UI 127.0.0.1:4000, Firebase CLI 15.30.0, Firebase JavaScript SDK 12.19.0, Firebase Admin Node SDK 14.4.0 with @google-cloud/firestore 9.1.0, @firebase/rules-unit-testing 5.0.2, and Node.js 22+. Mandatory work remains local/no-cost. The canonical lab is Standard Native Core; Enterprise Native Pipeline differences are labeled explicitly, and Firestore with MongoDB compatibility is not silently treated as the same client/rules surface.

Pinned versions and current release check

Firebase JavaScript SDK 12.19.0 was released 9 September 2026; Firebase Admin Node.js 14.4.0 was released 10 September 2026 and carries @google-cloud/firestore 9.1.0; Firebase CLI 15.30.0 was released 9 September 2026. Admin SDK 14.x requires Node.js 22 or higher. Keep these pins in the course lab instead of replacing them with floating latest tags.

Evidence and trust boundary

The Firestore emulator can prove deterministic evaluation of the rules source it loads, including mock Firebase Authentication identities/claims, query/rule compatibility, field validation, wildcard behavior, and a rules-coverage report. It does not prove production App Check attestation/enforcement, IAM/service-account policy, production index state, billing, abuse resistance, or regional latency. Mobile/web client SDK requests are governed by Security Rules. Admin/server libraries bypass Security Rules and must be constrained through IAM plus application-layer authorization. App Check is an additional app/device attestation signal; it is not user authorization.

1. The AtlasMart problem: a rules file is production code

A one-line rule change can expose every order even if application tests remain green. AtlasMart therefore treats firestore.rules, query builders, fixtures, and security tests as one versioned contract. CI must test capabilities the app needs and abuses an attacker would try: under-constrained list queries, guessed IDs, forged owner/tenant fields, stale role claims, broad recursive matches, and overlapping grants.

Test class Example Why it matters
Positive Alice reads own bounded orders query Prevents security fixes from breaking required UX
Negative Alice cannot query all t-acme orders Catches query broadening/data exposure
Enumeration Alice cannot get o-1303 by guessed ID Proves path knowledge is not authority
Forgery Alice cannot create order with customerId=u-2001 Rejects client-supplied identity spoofing
Claim boundary customer claim cannot use staff-only access Catches role-policy drift
Regression injection temporary broad allow causes expected negative test to fail Proves the test can detect the vulnerability

2. Deterministic harness: no production resources, no stale fixture state

package.json
{  "name": "atlasmart-firestore-ch13",  "private": true,  "type": "module",  "engines": { "node": ">=22" },  "dependencies": {    "firebase": "12.19.0",    "firebase-admin": "14.4.0"  },  "devDependencies": {    "firebase-tools": "15.30.0",    "@firebase/rules-unit-testing": "5.0.2"  }}
firebase.json
{  "firestore": {    "rules": "firestore.rules",    "indexes": "firestore.indexes.json"  },  "emulators": {    "firestore": { "port": 8080 },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
local setup
mkdir atlasmart-firestore-ch13 && cd atlasmart-firestore-ch13npm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2# Save firebase.json, firestore.rules and firestore.indexes.json from this chapter.npx firebase-tools@15.30.0 emulators:start \  --project demo-atlasmart-firestore \  --only firestore,auth
firestore.rules · adversarial Chapter 13 baseline
rules_version = '2';service cloud.firestore {  match /databases/{database}/documents {    function signedIn() {      return request.auth != null;    }    function tenantClaim() {      return signedIn() ? request.auth.token.tenantId : null;    }    function roleClaim() {      return signedIn() ? request.auth.token.role : null;    }    function isTenantStaff() {      return signedIn() && roleClaim() in ["support", "admin"];    }    function orderVisibleToCaller() {      return signedIn()        && resource.data.tenantId == tenantClaim()        && (resource.data.customerId == request.auth.uid || isTenantStaff());    }    function validOrderCreate() {      return signedIn()        && request.resource.data.keys().hasAll([             "tenantId", "customerId", "status", "createdAt", "schemaVersion"           ])        && request.resource.data.keys().hasOnly([             "tenantId", "customerId", "status", "createdAt",             "note", "schemaVersion"           ])        && request.resource.data.tenantId == tenantClaim()        && request.resource.data.customerId == request.auth.uid        && request.resource.data.status == "DRAFT"        && request.resource.data.schemaVersion == 5;    }    match /orders/{orderId} {      allow get: if orderVisibleToCaller();      allow list: if request.query.limit <= 25 && orderVisibleToCaller();      allow create: if validOrderCreate();      allow update, delete: if false;    }    match /tenantMemberships/{membershipId} {      allow read, write: if false;    }    match /profiles/{uid} {      allow get: if signedIn() && request.auth.uid == uid;      allow list: if false;      allow update: if signedIn() && request.auth.uid == uid        && request.resource.data.uid == resource.data.uid        && request.resource.data.tenantId == resource.data.tenantId        && request.resource.data.diff(resource.data).affectedKeys()             .hasOnly(["displayName", "locale"]);      allow create, delete: if false;    }    match /{document=**} {      allow read, write: if false;    }  }}
rules test setup and deterministic AtlasMart fixtures
import fs from "node:fs";import test, { before, beforeEach, after } from "node:test";import assert from "node:assert/strict";import {  initializeTestEnvironment,  assertSucceeds,  assertFails} from "@firebase/rules-unit-testing";import {  collection, doc, getDoc, getDocs, limit, orderBy,  query, setDoc, updateDoc, where} from "firebase/firestore";let env;before(async () => {  env = await initializeTestEnvironment({    projectId: "demo-atlasmart-firestore",    firestore: {      host: "127.0.0.1",      port: 8080,      rules: fs.readFileSync("firestore.rules", "utf8")    }  });});beforeEach(async () => {  await env.clearFirestore();  await env.withSecurityRulesDisabled(async context => {    const db = context.firestore();    const fixtures = [      ["orders/o-1301", {tenantId:"t-acme", customerId:"u-1001", status:"PAID", createdAt:new Date("2026-09-16T12:00:00Z"), schemaVersion:5}],      ["orders/o-1302", {tenantId:"t-acme", customerId:"u-1001", status:"DRAFT", createdAt:new Date("2026-09-16T12:05:00Z"), schemaVersion:5}],      ["orders/o-1303", {tenantId:"t-acme", customerId:"u-2001", status:"PAID", createdAt:new Date("2026-09-16T12:10:00Z"), schemaVersion:5}],      ["orders/o-1304", {tenantId:"t-other", customerId:"u-9001", status:"PAID", createdAt:new Date("2026-09-16T12:15:00Z"), schemaVersion:5}],      ["profiles/u-1001", {uid:"u-1001", tenantId:"t-acme", displayName:"Ava", locale:"en", schemaVersion:5}],      ["profiles/u-2001", {uid:"u-2001", tenantId:"t-acme", displayName:"Noah", locale:"en", schemaVersion:5}],      ["tenantMemberships/t-acme_u-1001", {tenantId:"t-acme", uid:"u-1001", active:true, role:"customer"}],      ["tenantMemberships/t-acme_staff-1", {tenantId:"t-acme", uid:"staff-1", active:true, role:"support"}]    ];    for (const [path, data] of fixtures) await setDoc(doc(db, path), data);  });});after(async () => { await env.cleanup(); });const aliceDb = () => env.authenticatedContext("u-1001", {  tenantId: "t-acme", role: "customer", plan: "standard"}).firestore();const noahDb = () => env.authenticatedContext("u-2001", {  tenantId: "t-acme", role: "customer", plan: "standard"}).firestore();const staffDb = () => env.authenticatedContext("staff-1", {  tenantId: "t-acme", role: "support"}).firestore();const otherTenantDb = () => env.authenticatedContext("u-9001", {  tenantId: "t-other", role: "customer"}).firestore();

@firebase/rules-unit-testing is emulator-aware and supports mock auth/claims. Seed fixtures only inside withSecurityRulesDisabled(); application requests must use authenticated/unauthenticated contexts with Rules enabled. Clear Firestore before each test so one test cannot accidentally make another pass.

Wrong approach: seed through an authenticated app context and relax Rules until setup works

That modifies the policy merely to make tests convenient. Seed with Rules disabled inside the testing utility, then exercise the real policy with normal contexts.

3. CI suite: positive, negative, query, claim and forged-payload cases

rules.test.mjs
test("owner query is allowed", async () => {  const db = aliceDb();  const q = query(    collection(db, "orders"),    where("tenantId", "==", "t-acme"),    where("customerId", "==", "u-1001"),    orderBy("createdAt", "desc"),    limit(20)  );  await assertSucceeds(getDocs(q));});test("broadened query is denied", async () => {  const db = aliceDb();  await assertFails(getDocs(query(    collection(db, "orders"),    where("tenantId", "==", "t-acme"),    limit(20)  )));});test("ID enumeration does not bypass authorization", async () => {  const db = aliceDb();  await assertFails(getDoc(doc(db, "orders/o-1303")));});test("forged owner field is denied", async () => {  const db = aliceDb();  await assertFails(setDoc(doc(db, "orders/o-forged"), {    tenantId: "t-acme",    customerId: "u-2001",    status: "DRAFT",    createdAt: new Date("2026-09-16T13:00:00Z"),    schemaVersion: 5  }));});test("staff claim grants only tenant-scoped read", async () => {  const db = staffDb();  await assertSucceeds(getDoc(doc(db, "orders/o-1303")));  await assertFails(getDoc(doc(db, "orders/o-1304")));});

4. Rules coverage is diagnostic evidence, not a security score

After the tests run, query the emulator’s :ruleCoverage endpoint. The HTML report shows expression evaluation counts and values; the JSON report can be retained as CI evidence. A high percentage does not mean every attacker strategy has been considered. Coverage cannot tell you that you forgot a new API route, a broadened query variant, or a production-only App Check/IAM path.

run and capture coverage
set -enpx firebase-tools@15.30.0 emulators:exec \  --project demo-atlasmart-firestore \  --only firestore,auth \  "node --test rules.test.mjs"# For an interactive run, leave emulators started and capture:curl -fsS   http://127.0.0.1:8080/emulator/v1/projects/demo-atlasmart-firestore:ruleCoverage   -o ruleCoverage.json

5. Controlled regression: prove CI detects exposure

Temporarily replace the owner/staff read condition with a dangerously broad authenticated read. Do this only in a disposable local branch/worktree or generated temporary rules file, never in production. The negative tests should fail because requests expected to be denied now succeed. Then restore the safe rule and re-run the suite.

unsafe mutation for failure injection
// TEMPORARY TEST MUTATION — deliberately insecure.match /orders/{orderId} {  allow get, list: if request.auth != null;}// Expected effect:// - "broadened query is denied" FAILS// - "ID enumeration does not bypass authorization" FAILS// Restore the safe rule immediately after proving the tests detect it.
expected CI evidence
FAIL broadened query is denied  expected permission-denied, but query succeededFAIL ID enumeration does not bypass authorization  expected permission-denied, but get succeededRepair: restore orderVisibleToCaller() + query limit contractRerun: all security tests PASS

6. Claims in CI: test policy, not token issuance machinery

authenticatedContext(uid, tokenOptions) lets the Rules test suite attach mock claims such as tenantId and role. This is ideal for testing how Rules interpret claims. It does not test Admin SDK claim issuance, a real ID token’s refresh lifecycle, revocation behavior, or Identity Platform tenancy. Keep those in separate integration/staging tests.

claim matrix
const customer = env.authenticatedContext("u-1001", {  tenantId: "t-acme", role: "customer"}).firestore();const support = env.authenticatedContext("staff-1", {  tenantId: "t-acme", role: "support"}).firestore();const foreignSupport = env.authenticatedContext("staff-2", {  tenantId: "t-other", role: "support"}).firestore();await assertSucceeds(getDoc(doc(support, "orders/o-1303")));await assertFails(getDoc(doc(customer, "orders/o-1303")));await assertFails(getDoc(doc(foreignSupport, "orders/o-1303")));

7. CI example without publishing secrets

.github/workflows/firestore-rules.yml
name: Firestore Ruleson:  pull_request:  push:    branches: [main]jobs:  rules:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-node@v4        with:          node-version: '22'          cache: npm      - run: npm ci      - run: |          npx firebase-tools@15.30.0 emulators:exec \            --project demo-atlasmart-firestore \            --only firestore,auth \            "node --test rules.test.mjs"

The demo project ID routes the suite to local emulators; no production service-account key or App Check debug token is needed for the mandatory Rules job. If you later add an isolated App Check integration job, store the debug token as a protected secret and never print it.

8. What CI still cannot prove

Concern Local Rules CI Separate evidence required
Rules allow/deny logic Yes Production deployment/version review
Mock role claims Yes Trusted claim issuance, propagation, revocation
App Check provider/enforcement No Isolated real Firebase project + metrics
Admin/server authorization No IAM policy + backend authorization tests
Production composite indexes Not authoritative Staging/production index deployment
Billing and p95/p99 latency No Production/staging metrics under bounded traffic
Incident response Can rehearse rule rollback files Operational runbook, audit/monitoring

Production judgment

Make Security Rules CI a required gate for changes to rules, query builders, auth-claim schema, and client-writable document fields. Keep negative tests stable and descriptive so a developer knows which threat boundary failed. Do not auto-accept generated rules because a syntax check passes; require adversarial tests and human review of new grants.

Lesson 5 turns this into an explicit threat model and abuse test plan covering enumeration, query broadening, forged data, replay and rule drift.

Knowledge check

  1. Why clear emulator data before each test?
  2. What does authenticatedContext test?
  3. Why deliberately inject an insecure rule?
  4. Is 100% expression coverage a guarantee of security?
  5. Should a Rules CI job need production credentials?
Review the answers

1. To prevent state leakage from making later tests pass or fail for the wrong reason.

2. How Rules interpret a chosen mock UID/token claim set; not real claim issuance or refresh.

3. To prove negative tests can detect the vulnerability rather than merely execute SDK calls.

4. No. Threat coverage and production-only integrations remain separate concerns.

5. No. The mandatory suite should run entirely against the Emulator Suite and a demo project ID.

Summary

AtlasMart’s rules are now tested like production authorization code: deterministic fixtures, explicit identities/claims, adversarial negative cases, regression injection, coverage evidence, and a clear list of what must still be verified outside the emulator.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.