How do I test a contract I consume?
@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.
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:
| Member | What it holds |
|---|---|
pinnedVersion | the version off the pinned document |
fetchedVersion | the version off the fetched document |
removed | the permission keys the fetched document dropped |
added | the permission keys the fetched document gained |
changed | each case whose two decisions disagree, with both decisions |
diff | whatever 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').; // -> falseclock.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:
| Export | What 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, explainFieldDecision | the 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.