Firestore Rules Testing Guide

This guide covers how to test Firestore security rules locally and in CI/CD pipelines using Vitest and the Firebase Emulator.

Overview

Firestore security rules are critical for data security. We use unit tests to verify:

  • Create validation: Field types and enum values
  • Read access: Visibility-based permissions
  • Update restrictions: Field-level update controls
  • Admin operations: Custom claims-based access

Project Structure

firebase-emulator/
├── firestore.rules          # Production security rules
├── firestore.rules.test.js  # Unit tests (26 tests)
├── package.json             # Dependencies and scripts
└── vitest.config.js         # Test configuration

Setup

Dependencies

{
  "devDependencies": {
    "@firebase/rules-unit-testing": "^3.0.0",
    "firebase-admin": "^12.0.0",
    "vitest": "^2.1.0"
  }
}

Vitest Configuration

// vitest.config.js
import { defineConfig } from "vitest/config";
 
export default defineConfig({
  test: {
    testTimeout: 30000,
    hookTimeout: 30000,
  },
});

The 30-second timeouts accommodate emulator startup and Firestore operations.

Running Tests

Local Development

cd firebase-emulator
npm test

This command:

  1. Starts the Firebase Firestore emulator
  2. Runs Vitest tests against the emulator
  3. Shuts down the emulator when complete

CI/CD Environment

npm run test:ci

Use this when the emulator is already running (e.g., via Docker Compose).

Test Structure

Test Setup

import { describe, test, beforeAll, afterAll, beforeEach } from "vitest";
import {
  initializeTestEnvironment,
  assertSucceeds,
  assertFails,
} from "@firebase/rules-unit-testing";
import fs from "fs";
import path from "path";
 
const PROJECT_ID = "archery-apprentice-test";
let testEnv;
 
beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: PROJECT_ID,
    firestore: {
      rules: fs.readFileSync(path.join(__dirname, "firestore.rules"), "utf8"),
      host: "localhost",
      port: 8080,
    },
  });
});
 
afterAll(async () => {
  await testEnv.cleanup();
});
 
beforeEach(async () => {
  await testEnv.clearFirestore();
});

Authentication Contexts

// Authenticated user
const db = testEnv.authenticatedContext("user123").firestore();
 
// Authenticated user with custom claims (admin)
const adminDb = testEnv
  .authenticatedContext("admin123", { admin: true })
  .firestore();
 
// Unauthenticated user
const unauthDb = testEnv.unauthenticatedContext().firestore();

Assert Helpers

// Expect operation to succeed
await assertSucceeds(
  db.collection("publishedRounds").doc("test1").set(validData)
);
 
// Expect operation to fail (permission denied)
await assertFails(
  db.collection("publishedRounds").doc("test1").set(invalidData)
);

Test Patterns

Create Validation Tests

Test that security rules validate field types and enum values:

describe("publishedRounds create validation", () => {
  const validPublishedRound = {
    userId: "user123",
    score: 300,           // Must be int
    arrowCount: 30,       // Must be int
    visibility: "PUBLIC", // Enum: PUBLIC, AUTHENTICATED_ONLY, PRIVATE
    status: "PUBLISHED",  // Enum: PENDING, PUBLISHED, REJECTED, WITHDRAWN
    verificationLevel: "SELF_REPORTED", // Enum validation
  };
 
  test("allows valid publishedRound create", async () => {
    const db = testEnv.authenticatedContext("user123").firestore();
    await assertSucceeds(
      db.collection("publishedRounds").doc("test1").set(validPublishedRound)
    );
  });
 
  test("rejects create when score is not an integer", async () => {
    const db = testEnv.authenticatedContext("user123").firestore();
    const invalidRound = { ...validPublishedRound, score: "300" };
    await assertFails(
      db.collection("publishedRounds").doc("test1").set(invalidRound)
    );
  });
 
  test("rejects create with invalid visibility value", async () => {
    const db = testEnv.authenticatedContext("user123").firestore();
    const invalidRound = { ...validPublishedRound, visibility: "INVALID" };
    await assertFails(
      db.collection("publishedRounds").doc("test1").set(invalidRound)
    );
  });
});

Read Access Tests

Test visibility-based read permissions:

describe("publishedRounds read access", () => {
  test("PUBLIC rounds are readable by anyone", async () => {
    // Setup: Create a public round as owner
    const ownerDb = testEnv.authenticatedContext("owner").firestore();
    await ownerDb.collection("publishedRounds").doc("public1").set({
      userId: "owner",
      visibility: "PUBLIC",
      // ... other fields
    });
 
    // Test: Unauthenticated can read
    const unauthDb = testEnv.unauthenticatedContext().firestore();
    await assertSucceeds(
      unauthDb.collection("publishedRounds").doc("public1").get()
    );
  });
 
  test("PRIVATE rounds only readable by owner", async () => {
    // Setup
    const ownerDb = testEnv.authenticatedContext("owner").firestore();
    await ownerDb.collection("publishedRounds").doc("private1").set({
      userId: "owner",
      visibility: "PRIVATE",
    });
 
    // Test: Other user cannot read
    const otherDb = testEnv.authenticatedContext("otherUser").firestore();
    await assertFails(
      otherDb.collection("publishedRounds").doc("private1").get()
    );
 
    // Test: Owner can read
    await assertSucceeds(
      ownerDb.collection("publishedRounds").doc("private1").get()
    );
  });
});

Admin Operations Tests

Test custom claims-based admin access:

describe("admin operations", () => {
  test("admin can delete any leaderboard entry", async () => {
    // Setup: Create entry as regular user
    const userDb = testEnv.authenticatedContext("user123").firestore();
    await userDb.collection("leaderboards").doc("entry1").set({
      userId: "user123",
      score: 100,
      // ... other fields
    });
 
    // Test: Admin can delete
    const adminDb = testEnv
      .authenticatedContext("admin123", { admin: true })
      .firestore();
    await assertSucceeds(
      adminDb.collection("leaderboards").doc("entry1").delete()
    );
  });
 
  test("admin audit logs are immutable", async () => {
    // Setup: Admin creates audit log
    const adminDb = testEnv
      .authenticatedContext("admin123", { admin: true })
      .firestore();
    await adminDb.collection("admin_audit_logs").doc("log1").set({
      adminId: "admin123",
      action: "DELETE_SCORE",
      timestamp: Date.now(),
    });
 
    // Test: Cannot update audit log
    await assertFails(
      adminDb
        .collection("admin_audit_logs")
        .doc("log1")
        .update({ reason: "Changed reason" })
    );
 
    // Test: Cannot delete audit log
    await assertFails(
      adminDb.collection("admin_audit_logs").doc("log1").delete()
    );
  });
});

Security Rules Reference

Helper Functions

// Check admin custom claim
function isAdmin() {
  return request.auth != null && request.auth.token.admin == true;
}
 
// Validate enum values
function isValidVisibility(visibility) {
  return visibility in ['PUBLIC', 'AUTHENTICATED_ONLY', 'PRIVATE'];
}
 
function isValidVerificationLevel(level) {
  return level in ['SELF_REPORTED', 'IMAGE_RECOGNITION', 'WITNESS_VERIFIED', 'TOURNAMENT'];
}

Type Validation

// In create rules
allow create: if request.auth != null &&
  request.auth.uid == request.resource.data.userId &&
  request.resource.data.score is int &&
  request.resource.data.arrowCount is int &&
  isValidVisibility(request.resource.data.visibility);

Field-Level Update Restrictions

// Only allow updating specific fields
allow update: if request.auth != null &&
  request.auth.uid == resource.data.userId &&
  request.resource.data.diff(resource.data)
    .affectedKeys()
    .hasOnly(['visibility', 'status', 'updatedAt']);

CI/CD Integration

The tests run as part of the Android CI workflow:

firestore_rules_test:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v5
 
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
 
    - name: Install Firebase CLI
      run: npm install -g firebase-tools
 
    - name: Install dependencies
      working-directory: firebase-emulator
      run: npm install
 
    - name: Run Firestore rules tests
      working-directory: firebase-emulator
      run: npm test

Best Practices

  1. Clear Firestore between tests: Use beforeEach with testEnv.clearFirestore() to ensure test isolation

  2. Test both success and failure cases: Every rule should have tests for allowed and denied operations

  3. Test all enum values: When validating enums, test all valid values pass and invalid values fail

  4. Use descriptive test names: Names should describe the scenario and expected outcome

  5. Test ownership verification: Ensure users can only modify their own data

  6. Test admin overrides: Verify admin custom claims grant appropriate elevated access

Troubleshooting

Emulator Connection Issues

If tests fail to connect:

# Check if emulator is running
lsof -i :8080
 
# Start emulator manually
firebase emulators:start --only firestore

Timeout Errors

Increase timeouts in vitest.config.js:

test: {
  testTimeout: 60000,
  hookTimeout: 60000,
}

Rules Not Loading

Ensure the rules file path is correct:

rules: fs.readFileSync(path.join(__dirname, "firestore.rules"), "utf8"),