Skip to content
tsbouncerpreview
recipe

Testing your store

The conformance suite, the golden dataset, and how to test the authorization you built.

TypeScript
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.

TypeScript
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:

TypeScript
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:

TypeScript
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.

TypeScript
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.

TypeScript
// 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.