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 testThis command:
- Starts the Firebase Firestore emulator
- Runs Vitest tests against the emulator
- Shuts down the emulator when complete
CI/CD Environment
npm run test:ciUse 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 testBest Practices
-
Clear Firestore between tests: Use
beforeEachwithtestEnv.clearFirestore()to ensure test isolation -
Test both success and failure cases: Every rule should have tests for allowed and denied operations
-
Test all enum values: When validating enums, test all valid values pass and invalid values fail
-
Use descriptive test names: Names should describe the scenario and expected outcome
-
Test ownership verification: Ensure users can only modify their own data
-
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 firestoreTimeout 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"),Related Documentation
- Global Admin System - Admin role architecture
- Admin Audit Trail - Immutable audit logging
- Admin Role Assignment - Setting up admin claims