Testing your store
The conformance suite, the golden dataset, and how to test the authorization you built.
import { expect, it, beforeEach, describe } from 'vitest';
import type { Cache, TupleStore } from '@tsbouncer/tsbouncer';
declare function myStore(): TupleStore;If you are writing a store
Run the conformance suite. It is the only gate that matters, and a store that does not pass is not finished.
storeConformance({
name: 'my-store',
skip: ['pagination'],
create: () => myStore(),
});Capabilities you list in skip are skipped. Capabilities you do not list are
asserted to work — so do not silence a failure you have not understood.
Caches get the same treatment, through the same package:
import { cacheConformance } from '@tsbouncer/testkit';
declare function myCache(): Cache;
cacheConformance({
name: 'my-cache',
create: () => myCache(),
});TTL tests are capability-gated like pagination: a cache without ttl support
skips them by listing it, and everything else is asserted.
Then run the golden dataset, which proves your store answers identically to every other one:
it('answers the shared dataset as every other store does', async () => {
assertGolden(await runGolden(myStore()));
});Testing the authorization you built
The model is the thing worth testing, and it is ordinary code. memoryStore is
process-local, so each test gets a clean graph with no teardown.
import { createAuthz, defineModel, defineType, permission, relation } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
const model = defineModel({
types: {
user: defineType({}),
document: defineType({
relations: { owner: relation(['user']) },
permissions: { read: permission.or('owner') },
}),
},
});
let authz: ReturnType<typeof createAuthz>;
beforeEach(async () => {
authz = createAuthz({ model, store: memoryStore() });
await authz.write([{ subject: 'user:alice', relation: 'owner', resource: 'document:1' }]);
});
describe('document.read', () => {
it('allows the owner', async () => {
expect(await authz.can('user:alice', 'document.read', 'document:1')).toBe(true);
});
it('allows nobody else', async () => {
expect(await authz.can('user:bob', 'document.read', 'document:1')).toBe(false);
});
});Test the pairs, not the cases
An exclusion, a permission, or a wildcard is only proven against the case next to it that says the opposite. A test suite of positive assertions will happily prove that everything is allowed.
// Two people who are both owner and editor, differing only by a ban.
it('the ban overrides a satisfied base', async () => {
expect(await authz.can('user:dave', 'document.write', 'document:1')).toBe(true);
expect(await authz.can('user:mallory', 'document.write', 'document:1')).toBe(false);
});Assert the two query paths agree
can and listResources are separate code paths over one graph, and nothing
structurally stops one from adopting a different reading of the model. That class of
bug has been real here twice.
it('listing never contradicts checking', async () => {
const { resources } = await authz.listResources({
subject: 'user:alice',
permission: 'document.read',
});
for (const resource of resources) {
expect(await authz.can('user:alice', 'document.read', resource)).toBe(true);
}
});Test the failure paths
Fail-closed behaviour is a feature, so it needs tests like any other.
it('a condition with no context denies', async () => {
expect(await authz.can('user:dana', 'document.read', 'document:1')).toBe(false);
});
it('a bad relation is rejected at write time', async () => {
await expect(
authz.write([{ subject: 'user:alice', relation: 'nope', resource: 'document:1' }]),
).rejects.toThrow();
});The contract report
@tsbouncer/testkit also exports a small contract runner: a shape for expectations
that state a guarantee in words, run against a real implementation, and report a
pass or fail per clause.
import {
assertGolden,
contract,
formatReport,
runContract,
runGolden,
storeConformance,
} from '@tsbouncer/testkit';
const guarantees = contract<void, void>(
'my-app guarantees',
'Claims about the model this app actually built.',
[
{
id: 'the-owner-can-write',
given: 'alice owns document:1',
when: 'checking document.write for user:alice',
expect: 'an owner can write their own document',
run: async () => {
if (!(await authz.can('user:alice', 'document.write', 'document:1'))) {
throw new Error('the owner was denied');
}
},
verify: () => {},
},
],
);
it('holds every guarantee', async () => {
const report = await runContract(guarantees, undefined);
expect(report.failed, formatReport(report)).toBe(0);
});A clause that throws fails. An exception is never a pass, because a contract that treats a crash as success reports green on a broken implementation.
Next
- Guarantees — the library’s own contract, and what it does not cover.