@entitle/testing
v0.2.0
Published
Test utilities and helpers for Entitle
Readme
@entitle/testing
Test utilities for Entitle, a
strongly-typed feature entitlements and access control system for TypeScript: a
subject-hierarchy builder, assertion helpers for explain() trees, a mock clock,
and an authorize stub for the HTTP adapters.
Three of these helpers changed behaviour in 0.1.1, and a test of yours may start failing because of it. Each change turns an assertion that was silently not made into one that is made. See Breaking changes in 0.1.1 below -- if one of them fails for you, the assertion was not being made before.
Install
pnpm add -D @entitle/testingSubject builder
import { buildSubject } from '@entitle/testing'
type Kind = 'user' | 'team' | 'org'
const org = buildSubject<Kind>('org-1', 'org')
const team = buildSubject<Kind>('team-1', 'team').withParent(org)
const user = buildSubject<Kind>('user-1', 'user').withParent(team)
user.toChain() // [user-1, team-1, org-1]
user.toResolver() // a HierarchyResolver for the whole graphName the kinds once with the type parameter: buildSubject otherwise infers the
kind from its argument, and withParent would only accept another builder of the
same kind.
toResolver()answers the subject it is asked about. It indexes every subject reachable from the builder it was called on, soresolver('team-1', 'team')gets the team's chain, not the user's. A subject that is not in the graph is an error, not a silent substitution. The chain is computed per call, so a parent added after the resolver was taken is part of the hierarchy the engine sees.toChain()de-duplicates by(kind, id). A diamond (userunder bothteamAandteamB, both underorg) lists the org once.withParentrefuses a cycle and names the path. Two separately built builders with the same(kind, id)are one subject, so making one the other's parent is a cycle.- A parent you built yourself is accepted.
SubjectBuilderis a structural type: an object of your own with atoChain()works, and its chain is appended as ancestors.
Assertion helpers
import { assertGranted, assertDenied, assertDeniedWith } from '@entitle/testing'
const tree = await engine.explain('user-1', 'user', 'seats')
assertGranted(tree) // throws if not granted
assertDenied(tree) // throws if not denied
assertDeniedWith(tree, { kind: 'condition_failed', policyId: 'pro' })assertDeniedWith compares policyId whenever it is given. Only two deny
reason kinds carry one -- condition_failed and policy_expired -- and asking a
kind that does not, such as no_policy, is a caller error and is reported as
one, naming the kind.
Mock clock
import { createMockClock } from '@entitle/testing'
const clock = createMockClock(new Date('2025-01-01'))
const engine = await createPolicyEngine({
features,
policies,
providers,
store,
resolver: async (subjectId, kind) => [{ id: subjectId, kind }],
clock: clock.now,
})
clock.advance(30 * 24 * 60 * 60 * 1000) // 30 days
clock.set(new Date('2025-06-01')) // or jump
console.log(engine.check)Every Date is copied in both directions, so nothing your test holds can move
the clock by accident. It also refuses to become unreadable: an
Invalid Date passed to createMockClock or set, and a non-finite advance,
throw. Every relational comparison against an Invalid Date is false, so a
clock that quietly went NaN would not make expiries fire early or late -- it
would make them never fire, and the suite would go green on a permanent
entitlement.
Authorize stub
Every Entitle HTTP adapter requires an authorize hook, so every test that
mounts one has to supply it. createAuthorizeStub is that hook plus the record
of what it was asked -- which is how a test asserts both what the adapter told
the hook and, when the expectation is that a request never got that far, that the
hook was never reached at all.
import { createAuthorizeStub } from '@entitle/testing'
const allow = createAuthorizeStub<Request>()
const deny = createAuthorizeStub<Request>({ ok: false, code: 'FORBIDDEN' })
// Or decide per request -- including throwing, which is how a test exercises
// the adapter's fail-closed path.
const admin = createAuthorizeStub<Request>((ctx) =>
ctx.access === 'write' ? { ok: false, code: 'FORBIDDEN' } : { ok: true, actorId: 'tester' },
)
allow.calls[0]?.operation // what the adapter passed the hookMemory store
createMemoryStore is re-exported from
@entitle/store-memory so
a test needs one import instead of two. It is that package's own function, not a
wrapper.
Breaking changes in 0.1.1
Each of these turns an assertion that was quietly skipped into one that runs. A failure means the assertion was not being made before.
assertDeniedWith(tree, { kind: 'no_policy', policyId: 'anything' })passed for anyno_policydenial: the comparison was guarded by'policyId' in reason, so it was skipped rather than failed for every kind that does not carry one. It runs wheneverpolicyIdis given now, and asking a kind that carries none is reported as a caller error.match.kindwasstring. It isDenyReason['kind'], so a misspelled kind is a compile error rather than a test that fails for the wrong reason.toResolver()discarded both arguments and answered every subject with the chain of the builder it was called on. It answers the subject it is asked about, and throws for one that is not in the graph.toChain()listed a diamond's shared ancestor twice, so a'sum'merge field counted its grant twice. De-duplicated by(kind, id).withParentdid no cycle check, so a mutual parent recursed until the stack overflowed. It refuses a cycle and names the path.createMockClock(...).advance(NaN)produced anInvalid Date, against which every comparison isfalse, so expiries never fired. A non-finiteadvance, an advance out ofDaterange, and anInvalid Dateall throw.
Links
@entitle/core-- the engine, and the security guidance for the HTTP adapters. Installed alongside this package atnode_modules/@entitle/core/README.md.@entitle/store-memory-- the store re-exported here.
License
MIT
