Skip to Content
AuthorizationHow do I test a contract I consume?

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

How do I test a contract I consume?

contractDrift from @evanion/acl/testing replays your own can questions against the contract you pinned and the one continuous integration (CI) fetched, and reports what moved between them.

Catching an acl contract change in your own CI

A contract is the matrix another service publishes for yours to adopt with parseMatrix, as How do I give my rules to another service? covers. Baize’s storefront draws its refund and cancel buttons from the contract the orders service publishes. A storefront test that writes a fixture matrix to stand in for that contract keeps a second copy of orders’ rules, and the suite goes on passing after orders edits one.

So the storefront pins the contract: it checks a copy into its repository as a JSON file beside its tests. CI fetches the current one from where orders serves it and hands both to contractDrift with the questions the storefront’s screens ask:

import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // The contract this consumer pinned, checked in beside the test. const : Matrix = { : 'orders@7', : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'bookseller', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, { : 'orders:order.cancel', : 'orders:order', : 'cancel', : [{ : 'anyone', : [] }], }, ], }; // What the producer publishes now, fetched in CI. The refund key is gone. const : Matrix = { : 'orders@8', : [ { : 'orders:order.cancel', : 'orders:order', : 'cancel', : [{ : 'anyone', : [] }], }, ], }; const = { : 'u1', : ['bookseller'] }; const = ({ , , // The decisions this application renders, named the way a reader reads them. : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, { : 'the cancel button', : , : 'orders:order', : 'cancel', }, ], }); .; // -> ['orders:order.refund'] ..(() => .); // -> ['the refund button'] .[0]?..; // -> 'unknown-action'

contractDrift({ pinned, fetched, cases }) adopts both documents with parseMatrix, the call How do I use rules another service published? covers, and answers every case against each. A case holds the arguments of can as subject, key, action and an optional object, plus a name for the report and an optional now. Its key is the object kind, the second argument of can, so 'orders:order' here. The report carries:

MemberWhat it holds
pinnedVersionthe version off the pinned document
fetchedVersionthe version off the fetched document
removedthe permission keys the fetched document dropped
addedthe permission keys the fetched document gained
changedeach case whose two decisions disagree, with both decisions
diffwhatever options.diff returned, or undefined when none given

A key in removed means orders broke a published contract. A decision in changed may be exactly what orders intended, and the storefront decides whether its screen still reads correctly. parseMatrix answers the removed refund key with unknown-action, so it arrives in changed and nothing throws partway through. contractDrift reports no fetchedAt when it adopts the two documents, so no freshness bound applies and no case answers stale-contract.

Failing the build on acl contract drift

assertNoContractDrift(options) runs contractDrift and throws a ContractDriftError when the producer removed a published key or a replayed decision changed. An added key passes, so a producer that widened its surface leaves the consumer’s build green:

import { , } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // One refund permission, held by whoever carries `role`. const = (: string, : string): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'staff', : [{ : 'subject.roles', : 'contains', : }], }, ], }, ], }); // The producer kept the key and narrowed the role that holds it. const = { : 'u1', : ['bookseller'] }; let : unknown; try { ({ : ('orders@7', 'bookseller'), : ('orders@8', 'supervisor'), : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, ], }); } catch () { = ; } const = as ; instanceof ; // -> true ..; // -> [] ..[0]?..; // -> 'no-rule-matched' (.).('\n')[0]; // -> 'contract drift, orders@7 -> orders@8'

The error carries the whole report on .report. Its message is describeContractDrift of that report, which prints removals first and spells them as a broken contract. ContractDriftError extends AclAssertionError, which every assertion in @evanion/acl/testing throws, and both are plain Error subclasses, so a try/catch and a framework’s toThrow(ContractDriftError) read the same failure.

Attaching the whole acl document diff to a drift report

options.diff takes a MatrixDiffer<D>, a (pinned, fetched) => D. contractDrift reads nothing out of the result and carries it onto report.diff, so passing diffMatrix types report.diff as the MatrixDiff that What did editing my rules change about access? reads:

import { } from '@evanion/acl'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const = { : 'staff', : [{ : 'subject.roles', : 'contains', : 'bookseller' }], } as ; const = { : 'supervisor', : [{ : 'subject.roles', : 'contains', : 'supervisor' }], } as ; const = (: string, : readonly unknown[]): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', , }, ], }) as Matrix; const = ({ : ('orders@7', []), : ('orders@8', [, ]), : , }); .; // -> [] .?.; // -> false .?..(() => .); // -> ['granted']

orders@8 added a supervisor branch to the refund rule. This call passes no cases, so changed is empty and only the diff reports the widening, as a granted finding. Cases say what this application’s screens do, and the diff says what moved in the document behind them.

Reaching stale-contract in an acl test on purpose

stale-contract is the refusal a consumer’s screen meets in production and never in development, because a document authored in the same process reports no fetchedAt and runs under no bound. fixtureClock(matrix, options?) computes a document’s freshness budget the way the engine does and names the two instants that bracket it:

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@7', : 300_000, : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [{ : 'anyone', : [] }], }, ], }; const = (, { : '2026-01-01T00:00:00Z' }); const = (, .); const = { : 'u1' }; const = .( , 'orders:order', 'refund', , ., ); .; // -> 'allow' // `assertRefused` names the key, the reason and the rule when it throws, so a // failure says which refusal arrived instead of `false !== true`. const = .( , 'orders:order', 'refund', , ., ); (, 'stale-contract').; // -> false

clock.fresh is the last instant every key still decides on, and clock.stale is the first that answers stale-contract. clock.options is the AccessOptions, the options object parseMatrix takes, that those two came from, so the evaluator and the test cannot disagree about the budget. The FixtureClock it returns also carries budget, expiresAt and at(ms) for an instant of your own. A document stating no maxStale makes fixtureClock itself throw MissingFreshnessBudgetError.

Asserting which acl refusal arrived

A bare expect(decision.allowed).toBe(true) fails with false !== true. The assertions below name the key, the reason, the rule and the missing paths:

ExportWhat it does
assertAllowed(decision, context?)returns the decision when it allows, and throws naming it otherwise
assertRefused(decision, reason?, context?)the same for a refusal, and asserts which reason arrived
assertFieldState(decision, field, state, context?)asserts one field of a canFields decision
explainDecision, explainFieldDecisionthe one-line renderings the three assertions throw
import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@8', : [ { : 'orders:order.update', : 'orders:order', : 'update', : [ { : 'staff', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], : { : ['note'] }, }, ], }; const = (); const = { : 'u1', : ['bookseller'] }; const = .(, 'orders:order', 'update'); (, 'the order editor').; // -> 'staff' (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"' // A shopper holds no role the rule names, so no allow rule matched. const = { : 'u2', : [] }; const = .(, 'orders:order', 'update'); (, 'no-rule-matched', 'the order editor').; // -> false // No proposed write, so the decision covers the row's fields and the listed names. const = .( , 'orders:order', 'update', { : 'held for pickup' }, 'write', ); (, 'note', 'allowed').; // -> true (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"; note: allowed (allow)' let : unknown; try { (, 'total', 'allowed'); } catch () { = ; } const = as ; instanceof ; // -> true .; // -> '"orders:order.update" has no field "total": it carries note'

A handler gates on allowed, and a screen picks its next move from reason: unevaluable asks for a refetch of the row, stale-contract for a refetch of the document, and denied for neither. So a test asserts which reason arrived. assertFieldState reports a field the decision carries no entry for separately from a field in the wrong state, because an absent name means the test named a field the permission never had.

What @evanion/acl/testing leaves out

A test that calls can and reads the decision needs no library. The entry point adds no faster way to build an evaluator, because building one over a 200-permission document takes single-digit milliseconds. It adds no shorter way to author a document either, because a second authoring surface produces test documents that differ from production ones in ways nobody reviews.

It imports no test framework: every helper returns a value or throws a plain Error, which vitest, node:test and jest each report the same way. @evanion/acl does not re-export any of it, so an application importing the engine pulls none of it into its bundle.

Last updated on