Write a Security Rules test suite
A ruleset is code that decides who sees what. It deserves tests like any other code that matters.
In Pyric a rules test is a small fixture, a TestCase, and a whole suite runs in-process in milliseconds. No Firebase project, no network, no deploy.
The fixtures
A TestCase describes one hypothetical request and the verdict you expect:
import { firestoreRules, type FirestoreCase } from 'pyric/rules';
const testCases: FirestoreCase[] = [
{
description: 'authenticated read on /notes is allowed',
expectation: 'ALLOW',
method: 'get',
path: 'notes/n1',
auth: { uid: 'alice' },
},
{
description: 'unauthenticated read on /notes is denied',
expectation: 'DENY',
method: 'get',
path: 'notes/n1',
// No auth field at all. request.auth is null.
},
{
description: 'owner can update their own note',
expectation: 'ALLOW',
method: 'update',
path: 'notes/n1',
auth: { uid: 'alice' },
resource: { ownerId: 'alice', title: 'old title' },
data: { ownerId: 'alice', title: 'new title' },
},
{
description: 'admin can read any admin doc',
expectation: 'ALLOW',
method: 'get',
path: 'admin/config/secrets/api-keys',
auth: { uid: 'root', token: { role: 'admin' } },
},
];
The fields map straight onto what the rule sees. resource is the existing document, data is the proposed write, auth.uid becomes request.auth.uid, and auth.token becomes request.auth.token for rules that check custom claims.
Run the suite
const result = firestoreRules(source).simulate(testCases);
const { passed, failed, unsupported, cases } = result;
console.log(`${passed} passed · ${failed} failed · ${unsupported} unsupported`);
A failed case means the simulator’s verdict disagreed with your expectation, and its trace shows which rule decided. An unsupported case means the simulator hit a feature it does not implement and abstained. It is not counted as a failure, and it is never a guess.
Two fixture fields worth knowing before your suite grows:
- For
updateand merge writes, setwriteModeso the simulator projects the post-write document the way Firestore does. - For rules that read
request.time, pinrequestTimeto an ISO timestamp so the verdict does not depend on the clock. Pass{ testCases }tolintFirestoreRulesandREQUEST_TIME_NOT_PINNEDflags the cases you missed.
Gate CI on it
The suite is a script, so CI is one exit code away:
if (result.failed > 0) {
for (const r of result.cases) {
if (!r.passed) console.error(`FAILED: ${r.description}`);
}
process.exit(1);
}
Sub-millisecond per case once the rules are parsed. There is no reason not to run this on every push.
Use Google’s Rules Test API when production authority matters
The hosted Rules Test API evaluates your cases on Google’s servers, in the same engine production uses, without deploying anything. It takes the same TestCase objects and returns the same result shape. It needs a real project and credentials:
import { TestFirestoreRulesHandler } from 'pyric/rules';
import { fromServiceAccount } from '@pyric/cli/credentials/node';
const scope = await fromServiceAccount('./service-account.json');
const remote = await new TestFirestoreRulesHandler()
.execute(scope, source, testCases);
The practical pattern is local-first: run everything through the simulator, then send only the UNSUPPORTED cases to the hosted engine.
const escalate = testCases.filter(
(_, i) => result.cases[i].unsupported,
);
if (escalate.length > 0) {
const remote = await new TestFirestoreRulesHandler()
.execute(scope, source, escalate);
}
Each hosted call is one HTTP round-trip, tens to hundreds of milliseconds. The simulator itself is held to that engine’s answers by a parity corpus that runs in CI, so for most suites the local verdicts are the same verdicts, sooner.
Run the suite through an agent
An agent can run the local loop through firestore_simulate_rules, which means the rules it writes can arrive with explicit passing cases instead of a promise. See Work with an agent.
Where to go next
A test failure tells you a verdict was wrong. A denial explains why. Read read a denial and understand it. Before those rules ship, see ship to production.