diff --git a/.github/workflows/policy-conformance.yml b/.github/workflows/policy-conformance.yml new file mode 100644 index 0000000..a156433 --- /dev/null +++ b/.github/workflows/policy-conformance.yml @@ -0,0 +1,37 @@ +# Issue #226: run the example policy engines, including the shared pre-sign +# policy conformance suite, against the SDK built from this PR. No secrets; no +# network beyond npm. +name: Policy Conformance +on: + pull_request: + paths: + - packages/sdk/** + - examples/x402-policy-daily-budget/** + - .github/workflows/policy-conformance.yml + push: + branches: + - main + paths: + - packages/sdk/** + - examples/x402-policy-daily-budget/** + - .github/workflows/policy-conformance.yml +permissions: + contents: read +jobs: + daily-budget: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + # --legacy-peer-deps: same reason as sdk-test.yml. + - run: npm install --no-audit --no-fund --legacy-peer-deps + working-directory: packages/sdk + - run: npm run build + working-directory: packages/sdk + # The example links the SDK with file:../../packages/sdk, so this is the build above. + - run: npm ci --no-audit --no-fund + working-directory: examples/x402-policy-daily-budget + - run: npm test + working-directory: examples/x402-policy-daily-budget diff --git a/examples/x402-policy-daily-budget/README.md b/examples/x402-policy-daily-budget/README.md index 765cccc..0d6b47a 100644 --- a/examples/x402-policy-daily-budget/README.md +++ b/examples/x402-policy-daily-budget/README.md @@ -34,7 +34,7 @@ npm run typecheck npm start # offline demo with fake contexts ``` -The SDK is referenced as `file:../../packages/sdk` and only its types are imported, so build it once (`cd ../../packages/sdk && npm install --legacy-peer-deps && npm run build`, as the SDK CI does; `@stellar/mpp` 0.7.1 still declares a peer of `@stellar/stellar-sdk` ^15) before `typecheck`. +The SDK is referenced as `file:../../packages/sdk`, so build it once (`cd ../../packages/sdk && npm install --legacy-peer-deps && npm run build`, as the SDK CI does; `@stellar/mpp` 0.7.1 still declares a peer of `@stellar/stellar-sdk` ^15) before `test` and `typecheck`. `test/conformance.test.ts` runs the SDK's shared policy conformance suite (#226) against this engine through the SDK's real pre-sign gate; CI runs it on every PR that touches the SDK or this example. ## Multi-process: map it to SQL diff --git a/examples/x402-policy-daily-budget/test/conformance.test.ts b/examples/x402-policy-daily-budget/test/conformance.test.ts new file mode 100644 index 0000000..7436eaa --- /dev/null +++ b/examples/x402-policy-daily-budget/test/conformance.test.ts @@ -0,0 +1,13 @@ +// The shared pre-sign policy conformance suite (#226) against DailyBudgetPolicy, +// through the SDK's real gate. Needs the SDK built (see README). +import { runPolicyConformance } from 'nirium/dist/x402-policy-conformance.js'; +import { DailyBudgetPolicy } from '../src/daily-budget-policy.ts'; + +runPolicyConformance({ + name: 'daily-budget', + // A fresh policy per case, so one case's spend never affects another. + createHook: () => new DailyBudgetPolicy({ dailyCap: 10_000_000n }).hook(), // 1 USDC a day + allow: { amount: '2500000' }, // 0.25 USDC + deny: { amount: '10000001' }, // just over the cap + // No `wait`: this engine never answers WAIT, so the suite turns its ALLOW into one. +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 499a231..73ae2fb 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -162,6 +162,27 @@ Only an `ALLOW` that echoes this authorization's `contextHash`, and is still val **What this does not do.** It is a check on the agent side, not account-level enforcement: code in the same process that holds the raw signer can still call it directly (the L02 limit in #96; so can code that replaces its `signAuthEntry`, which is looked up at call time), and nothing on-chain enforces the policy. It does not solve pending-capacity reservation (L01 in #96: concurrent requests that observe the same remaining capacity): two calls evaluated at the same time can each fit a limit that together they exceed, and an aggregate cap has to be held by your policy. Discussed in [#96](https://github.com/nirium-protocol/nirium/issues/96), where @CodeDeityX laid out the agent-side cases this hook is built against. +**Conformance test for your engine.** The SDK ships one shared test any engine can run ([#226](https://github.com/nirium-protocol/nirium/issues/226)). It puts your hook through the real gate and the real `@stellar/stellar-sdk` signing path, with no RPC and no network, and checks seven cases. An `ALLOW` that echoes the exact `contextHash` must sign exactly once. `DENY`, `WAIT`, an `ALLOW` with a wrong `contextHash`, an `ALLOW` past its `expiresAt`, a malformed answer and an answer after the timeout must each sign nothing. You give it one payment your engine allows and one it denies, plus one it answers `WAIT` to if it ever does. The last four cases start from your engine's own `ALLOW` and change it the way each case names. It is experimental like the hook and is not exported from the package index, so import it from `dist`: + +```typescript +// test/conformance.test.ts, run with `node --test` (via tsx for TypeScript) +import { runPolicyConformance } from 'nirium/dist/x402-policy-conformance.js'; + +runPolicyConformance({ + name: 'my engine', + createHook: () => myEngine.hook(), // called once per case: return a fresh one + allow: { amount: '2500000' }, // base units; payTo, asset, url, method are optional + deny: { amount: '50000000' }, + // wait: { amount: '1', payTo: 'G...' }, // only if your engine answers WAIT +}); +``` + +**What passing means.** Your engine answers correctly (allows what it should, denies what it should, binds its `ALLOW` to the exact `contextHash`) and the gate fails closed around it. It does **not** validate your policy logic: it only checks the two or three payments you give it, and several cases (wrong hash, expired, malformed, timeout) mostly exercise the gate on top of your engine's `ALLOW`. Test the rules themselves with your own tests. + +Your hook is used as-is, so a class instance works: its prototype methods (`currentVersion`, `onDecision`) are called on the instance. The timeout case runs the gate with `timeoutMs` 100 (change it with `runPolicyConformance(target, { timeoutMs })`); your engine still gets its own `timeoutMs` (default 5000 ms) to answer, so a slow remote engine does not fail that case. + +Each case is a `node:test` test. To use another runner, `policyConformanceCases(target)` returns the cases as `{ name, run }`. [`examples/x402-policy-daily-budget`](../../examples/x402-policy-daily-budget) runs it in CI. + ### MPP Charge (experimental) ```typescript diff --git a/packages/sdk/src/x402-policy-conformance.ts b/packages/sdk/src/x402-policy-conformance.ts new file mode 100644 index 0000000..d3e09c2 --- /dev/null +++ b/packages/sdk/src/x402-policy-conformance.ts @@ -0,0 +1,362 @@ +// ═══════════════════════════════════════════════════════════════ +// Conformance suite for pre-sign policy engines (#226). +// +// Any engine that plugs into initX402({ policy }) can run the same seven +// cases through the SDK's real gate (createPolicyGatedSigner) and the real +// stellar-sdk: ALLOW with the exact contextHash, DENY, WAIT, a wrong +// contextHash, an expired expiresAt, a malformed answer and a timeout. Only +// the first one may end in a signature. +// +// The engine supplies two payments: one it must ALLOW and one it must DENY +// (and, optionally, one it must WAIT). The last four cases start from the +// engine's own ALLOW for the allow payment and change it in the way each case +// names, so they check that the engine's answer and the gate together still +// fail closed, without asking the engine to misbehave. +// +// No RPC, no network, nothing broadcast. Experimental, like the hook itself; +// not exported from the package index. Import it from +// `nirium/dist/x402-policy-conformance.js`. +// ═══════════════════════════════════════════════════════════════ + +import { test as nodeTest } from 'node:test'; +import { + Keypair, Networks, xdr, Address, nativeToScVal, authorizeEntry, hash, +} from '@stellar/stellar-sdk'; +import { + createPolicyGatedSigner, X402PolicyError, + type X402PolicyHook, type X402PolicyDecision, type X402PolicyRecord, + type X402PaymentRequirementsView, type X402BaseSigner, +} from './x402-policy'; + +/** One payment the engine is asked about. Unset fields get test defaults. */ +export interface ConformancePayment { + /** Amount in the asset's base units (USDC has 7 decimals). */ + amount: string; + /** Destination G... address. Default: a fresh random account. */ + payTo?: string; + /** Asset contract C... address. Default: testnet USDC. */ + asset?: string; + /** Default 'https://conformance.test/paid'. */ + url?: string; + /** Default 'GET'. */ + method?: string; +} + +export interface PolicyConformanceTarget { + /** Used in test names. */ + name: string; + /** + * Returns the hook to test. Called once per case, so state such as a spent + * budget does not leak between cases. + */ + createHook: () => X402PolicyHook | Promise; + /** A payment the engine must ALLOW. */ + allow: ConformancePayment; + /** A payment the engine must DENY. */ + deny: ConformancePayment; + /** + * A payment the engine itself answers WAIT for. Engines that never wait + * leave it out; the WAIT case then turns the engine's ALLOW into a WAIT. + */ + wait?: ConformancePayment; + /** Payer keypair. Default: a fresh random one (never funded). */ + payer?: Keypair; +} + +export interface ConformanceOptions { + /** + * The gate under test. Default: the SDK's createPolicyGatedSigner. Only the + * suite's own meta-tests replace it, to prove the refusal cases catch a + * gate that signs when it should not. + */ + gate?: typeof createPolicyGatedSigner; + /** + * The gate's deadline in the timeout case. Default 100. The engine's own + * answer is awaited outside this deadline, so it does not need to be + * faster than this; it only bounds how long that case's gate waits. + */ + timeoutMs?: number; +} + +export interface ConformanceCase { + name: string; + run: () => Promise; +} + +const USDC_TESTNET = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; + +class ConformanceFailure extends Error { + constructor(message: string) { super(message); this.name = 'ConformanceFailure'; } +} +const check = (cond: unknown, message: string): void => { + if (!cond) throw new ConformanceFailure(message); +}; + +let nonceSeq = BigInt(Date.now()) * 1000n; + +/** The cases, as plain functions, so they can be run under any test runner. */ +export function policyConformanceCases( + target: PolicyConformanceTarget, + options: ConformanceOptions = {}, +): ConformanceCase[] { + const gate = options.gate ?? createPolicyGatedSigner; + const payer = target.payer ?? Keypair.random(); + const defaultPayTo = Keypair.random().publicKey(); + const timeoutMs = options.timeoutMs ?? 100; + + const requirements = (p: ConformancePayment): X402PaymentRequirementsView => ({ + scheme: 'exact', network: 'stellar:testnet', asset: p.asset ?? USDC_TESTNET, + payTo: p.payTo ?? defaultPayTo, amount: p.amount, maxTimeoutSeconds: 60, + }); + + /** The transfer(from,to,amount) auth entry ExactStellarScheme would ask to sign. */ + const entry = (r: X402PaymentRequirementsView) => new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(new xdr.SorobanAddressCredentials({ + address: new Address(payer.publicKey()).toScAddress(), + nonce: xdr.Int64.fromString(String(nonceSeq++)), + signatureExpirationLedger: 0, + signature: xdr.ScVal.scvVoid(), + })), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(new xdr.InvokeContractArgs({ + contractAddress: new Address(r.asset).toScAddress(), + functionName: 'transfer', + args: [ + nativeToScVal(payer.publicKey(), { type: 'address' }), + nativeToScVal(r.payTo, { type: 'address' }), + nativeToScVal(r.amount, { type: 'i128' }), + ], + })), + subInvocations: [], + }), + }); + + interface Attempt { + signed: boolean; + signerCalls: number; + error?: unknown; + records: X402PolicyRecord[]; + /** What the engine itself answered, before any change a case makes. */ + engineDecisions: X402PolicyDecision[]; + contextHashes: string[]; + } + + /** + * Runs one payment through the gate. `alter` changes the engine's answer + * (or the hook) the way a case needs; the engine is still asked first. + */ + const attempt = async ( + p: ConformancePayment, + alter?: (hook: X402PolicyHook, seen: Attempt) => X402PolicyHook, + ): Promise => { + const seen: Attempt = { signed: false, signerCalls: 0, records: [], engineDecisions: [], contextHashes: [] }; + const engine = await target.createHook(); + // Read each field explicitly and call methods on the engine itself: + // a spread would drop prototype methods of a class-based engine (so + // currentVersion, and with it the stale-version check, would vanish) + // and would call them without their `this`. + let hook: X402PolicyHook = { + timeoutMs: engine.timeoutMs, + requireExpiry: engine.requireExpiry, + evaluate: async (ctx, opts) => { + seen.contextHashes.push(ctx.contextHash); + const d = await engine.evaluate(ctx, opts); + seen.engineDecisions.push(d); + return d; + }, + onDecision: (r) => { seen.records.push(r); return engine.onDecision?.(r); }, + }; + if (engine.currentVersion !== undefined) { + hook.currentVersion = (ctx, opts) => engine.currentVersion!(ctx, opts); + } + if (engine.now !== undefined) hook.now = () => engine.now!(); + if (alter) hook = alter(hook, seen); + const base: X402BaseSigner = { + address: payer.publicKey(), + signAuthEntry: async (preimageXdr) => { + seen.signerCalls++; + const pre = xdr.HashIdPreimage.fromXDR(preimageXdr, 'base64'); + return { signedAuthEntry: payer.sign(hash(pre.toXDR())).toString('base64'), signerAddress: payer.publicKey() }; + }, + }; + const r = requirements(p); + const call = { url: p.url ?? 'https://conformance.test/paid', method: p.method ?? 'GET', requirements: r }; + const gated = gate(base, hook, () => call); + try { + // What stellar-sdk's signAuthEntries does: it also verifies the + // returned signature against sha256(preimage). + await authorizeEntry(entry(r), async (preimage: any) => { + const { signedAuthEntry } = await gated.signAuthEntry(preimage.toXDR('base64'), { address: payer.publicKey() }); + return Buffer.from(signedAuthEntry, 'base64'); + }, 1_000_000, Networks.TESTNET); + seen.signed = true; + } catch (e) { + seen.error = e; + } + return seen; + }; + + const describeError = (e: unknown) => + e instanceof X402PolicyError ? `${e.outcome}: ${e.message}` : String((e as any)?.message ?? e); + + /** A refusal case: nothing signed, the gate refused with `outcome`. */ + const expectRefused = (a: Attempt, outcome: string) => { + check(a.signerCalls === 0, `expected no signature (${outcome}), but the signer was called ${a.signerCalls} time(s)`); + check(!a.signed, `expected no signature (${outcome}), but the authorization was signed`); + check(a.error instanceof X402PolicyError, + `expected X402PolicyError ${outcome}, got ${a.error === undefined ? 'no error' : describeError(a.error)}`); + check((a.error as X402PolicyError).outcome === outcome, + `expected outcome ${outcome}, got ${describeError(a.error)}`); + check(!a.records.some((r) => r.outcome === 'SIGNED'), `onDecision saw SIGNED in a ${outcome} case`); + }; + + /** The altered cases only mean something if the engine allowed first. */ + const expectEngineAllowed = (a: Attempt) => { + const d = a.engineDecisions[0]; + check(d?.decision === 'ALLOW', + `the engine must ALLOW target.allow for this case to mean anything; it answered ${d ? JSON.stringify(d.decision) : 'nothing'}`); + }; + + /** + * Replaces the engine's ALLOW with `change(allow)`; other answers pass + * through. Unless the case is about the hash, the ALLOW is bound to this + * context first, so each case tests only the fault it names (an engine + * that gets the hash wrong already fails the ALLOW case). + */ + const rewriteAllow = (change: (d: X402PolicyDecision) => unknown, keepHash = false) => + (hook: X402PolicyHook): X402PolicyHook => ({ + ...hook, + evaluate: async (ctx, opts) => { + const d = await hook.evaluate(ctx, opts); + if (d?.decision !== 'ALLOW') return d; + return change(keepHash ? d : { ...d, contextHash: ctx.contextHash }) as X402PolicyDecision; + }, + }); + + const cases: ConformanceCase[] = [ + { + name: 'ALLOW with the exact contextHash signs exactly once', + run: async () => { + const a = await attempt(target.allow); + const d = a.engineDecisions[0]; + check(d !== undefined, 'the engine was never asked'); + check(d.decision === 'ALLOW', `expected the engine to ALLOW target.allow, it answered ${JSON.stringify(d.decision)}${d.reason ? ` (${d.reason})` : ''}`); + check(d.contextHash === a.contextHashes[0], + `ALLOW must echo ctx.contextHash exactly: got ${JSON.stringify(d.contextHash)}, expected ${a.contextHashes[0]}`); + check(a.signed && a.error === undefined, `expected a signature, got ${describeError(a.error)}`); + check(a.signerCalls === 1, `expected the signer to be called once, it was called ${a.signerCalls} time(s)`); + const outcomes = a.records.map((r) => r.outcome).join(','); + check(outcomes === 'ALLOWED,SIGNED', `expected onDecision ALLOWED,SIGNED, got ${outcomes || 'nothing'}`); + }, + }, + { + name: 'DENY signs nothing', + run: async () => { + const a = await attempt(target.deny); + check(a.engineDecisions[0]?.decision === 'DENY', + `expected the engine to DENY target.deny, it answered ${JSON.stringify(a.engineDecisions[0]?.decision)}`); + expectRefused(a, 'DENY'); + }, + }, + { + name: target.wait ? 'WAIT signs nothing' : 'WAIT signs nothing (engine ALLOW turned into WAIT)', + run: async () => { + if (target.wait) { + const a = await attempt(target.wait); + check(a.engineDecisions[0]?.decision === 'WAIT', + `expected the engine to WAIT on target.wait, it answered ${JSON.stringify(a.engineDecisions[0]?.decision)}`); + expectRefused(a, 'WAIT'); + return; + } + const a = await attempt(target.allow, rewriteAllow((d) => ({ ...d, decision: 'WAIT', reason: 'conformance: wait' }))); + expectEngineAllowed(a); + expectRefused(a, 'WAIT'); + }, + }, + { + name: 'ALLOW with a wrong contextHash signs nothing', + run: async () => { + const a = await attempt(target.allow, rewriteAllow((d) => ({ + ...d, + contextHash: typeof d.contextHash === 'string' && d.contextHash.length > 0 + ? (d.contextHash[0] === '0' ? '1' : '0') + d.contextHash.slice(1) + : '0'.repeat(64), + }), true)); + expectEngineAllowed(a); + expectRefused(a, 'UNBOUND'); + }, + }, + { + name: 'ALLOW with an expired expiresAt signs nothing', + run: async () => { + const a = await attempt(target.allow, rewriteAllow((d) => ({ ...d, expiresAt: Date.now() - 1 }))); + expectEngineAllowed(a); + expectRefused(a, 'EXPIRED'); + }, + }, + // Several shapes, each a separate gate run. + ...([ + ['no decision object', () => null], + ['an unknown verdict', (d: X402PolicyDecision) => ({ ...d, decision: 'allow' })], + ['a non-numeric expiresAt', (d: X402PolicyDecision) => ({ ...d, expiresAt: 'soon' })], + ] as Array<[string, (d: X402PolicyDecision) => unknown]>).map(([what, change]) => ({ + name: `a malformed answer (${what}) signs nothing`, + run: async () => { + const a = await attempt(target.allow, rewriteAllow(change)); + expectEngineAllowed(a); + expectRefused(a, 'MALFORMED'); + }, + })), + { + name: 'an answer after the timeout signs nothing', + run: async () => { + let aborted = false; + // The engine is asked outside the gate's deadline, with its own + // signal and the budget it would get in real use (its own + // timeoutMs, else the gate's default 5000 ms), so a correct + // engine slower than this case's timeoutMs (a remote one, say) + // still gets to answer. The gate only sees that answer arrive + // after its deadline. + let engineAnswer: Promise = Promise.resolve(); + const a = await attempt(target.allow, (hook) => ({ + ...hook, + timeoutMs, + evaluate: (ctx, opts) => { + const own = new AbortController(); + const t = setTimeout(() => own.abort(), hook.timeoutMs ?? 5_000); + engineAnswer = hook.evaluate(ctx, { signal: own.signal }) + .catch(() => undefined) + .finally(() => clearTimeout(t)); + return new Promise((resolve) => { + opts.signal.addEventListener('abort', () => { + aborted = true; + void engineAnswer.then((d) => resolve(d as X402PolicyDecision)); + }, { once: true }); + }); + }, + })); + await engineAnswer; + expectEngineAllowed(a); + expectRefused(a, 'TIMEOUT'); + check(aborted, 'the gate did not abort the engine signal on timeout'); + }, + }, + ]; + return cases; +} + +/** + * Registers every case as a node:test test. Call it at the top level of a + * test file: + * + * runPolicyConformance({ name: 'my engine', createHook, allow, deny }); + */ +export function runPolicyConformance( + target: PolicyConformanceTarget, + options: ConformanceOptions & { test?: (name: string, fn: () => Promise) => unknown } = {}, +): void { + const register = options.test ?? ((name: string, fn: () => Promise) => nodeTest(name, fn)); + for (const c of policyConformanceCases(target, options)) { + register(`policy conformance (${target.name}): ${c.name}`, c.run); + } +} diff --git a/packages/sdk/test/x402-policy-conformance.test.ts b/packages/sdk/test/x402-policy-conformance.test.ts new file mode 100644 index 0000000..6571a4d --- /dev/null +++ b/packages/sdk/test/x402-policy-conformance.test.ts @@ -0,0 +1,199 @@ +/** + * Meta-tests for the policy conformance suite (#226): a correct engine passes + * every case, and each broken engine (or a gate that signs when it should not) + * fails exactly the cases it should. + * + * Imports the suite from dist (it imports the gate without a .ts extension, + * which --experimental-strip-types cannot resolve), so build first: + * `npm run build && npm test`, as SDK CI does. + */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Keypair } from '@stellar/stellar-sdk'; +import { policyConformanceCases, runPolicyConformance } from '../dist/x402-policy-conformance.js'; +import { createPolicyGatedSigner } from '../dist/x402-policy.js'; +import type { PolicyConformanceTarget } from '../src/x402-policy-conformance.ts'; +import type { X402PolicyHook, X402PolicyDecision, X402PolicyContext } from '../src/x402-policy.ts'; + +const blocked = Keypair.random().publicKey(); + +/** Reference engine: ALLOW up to 1_000_000 base units, DENY above, WAIT for `blocked`. */ +function engine(over: Partial = {}, answer?: (ctx: X402PolicyContext, d: X402PolicyDecision) => X402PolicyDecision): X402PolicyHook { + return { + requireExpiry: true, + currentVersion: async () => 'v1', + evaluate: async (ctx) => { + let d: X402PolicyDecision; + if (ctx.authorization.to === blocked) d = { decision: 'WAIT', reason: 'needs a human' }; + else if (BigInt(ctx.authorization.amount) > 1_000_000n) d = { decision: 'DENY', reason: 'over cap' }; + else d = { decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: 'v1', expiresAt: Date.now() + 2_000 }; + return answer ? answer(ctx, d) : d; + }, + ...over, + }; +} + +const target = (createHook: () => X402PolicyHook, extra: Partial = {}): PolicyConformanceTarget => ({ + name: 'reference', createHook, allow: { amount: '500000' }, deny: { amount: '2000000' }, ...extra, +}); + +/** Runs every case and returns the names of the ones that failed. */ +async function failing(t: PolicyConformanceTarget, opts = {}): Promise { + const out: string[] = []; + for (const c of policyConformanceCases(t, opts)) { + try { await c.run(); } catch { out.push(c.name); } + } + return out; +} + +const ALL = [ + 'ALLOW with the exact contextHash signs exactly once', + 'DENY signs nothing', + 'WAIT signs nothing (engine ALLOW turned into WAIT)', + 'ALLOW with a wrong contextHash signs nothing', + 'ALLOW with an expired expiresAt signs nothing', + 'a malformed answer (no decision object) signs nothing', + 'a malformed answer (an unknown verdict) signs nothing', + 'a malformed answer (a non-numeric expiresAt) signs nothing', + 'an answer after the timeout signs nothing', +]; +const ALLOW_CASE = ALL[0]; +const DERIVED = ALL.slice(2); // built on the engine's own ALLOW + +// The suite itself, registered as node:test tests, against the reference engine. +runPolicyConformance(target(() => engine())); +runPolicyConformance(target(() => engine(), { name: 'reference with native WAIT', wait: { amount: '1', payTo: blocked } })); + +describe('conformance suite has teeth', () => { + test('lists the seven cases from #226 (malformed split in three shapes)', () => { + assert.deepEqual(policyConformanceCases(target(() => engine())).map((c) => c.name), ALL); + }); + + test('a correct engine passes every case', async () => { + assert.deepEqual(await failing(target(() => engine())), []); + }); + + test('an engine that ignores contextHash fails the ALLOW case', async () => { + const f = await failing(target(() => engine({}, (_c, d) => d.decision === 'ALLOW' ? { ...d, contextHash: 'f'.repeat(64) } : d))); + assert.deepEqual(f, [ALLOW_CASE]); + }); + + test('an engine that omits contextHash fails the ALLOW case', async () => { + const f = await failing(target(() => engine({}, (_c, d) => d.decision === 'ALLOW' ? { ...d, contextHash: undefined } : d))); + assert.deepEqual(f, [ALLOW_CASE]); + }); + + test('an engine that ALLOWs everything fails the DENY case', async () => { + const f = await failing(target(() => engine({}, (ctx) => ({ decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: 'v1', expiresAt: Date.now() + 2_000 })))); + assert.deepEqual(f, ['DENY signs nothing']); + }); + + test('an engine that DENYs everything fails ALLOW and every case built on ALLOW', async () => { + const f = await failing(target(() => engine({}, () => ({ decision: 'DENY' })))); + assert.deepEqual(f, [ALLOW_CASE, ...DERIVED]); + }); + + test('an engine with no WAIT fails a declared native WAIT case', async () => { + const f = await failing(target(() => engine({}, (ctx, d) => d.decision === 'WAIT' + ? { decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: 'v1', expiresAt: Date.now() + 2_000 } : d), + { wait: { amount: '1', payTo: blocked } })); + assert.deepEqual(f, ['WAIT signs nothing']); + }); + + test('an engine that hangs fails every case that needs an answer', async () => { + const hang = () => engine({ timeoutMs: 50, evaluate: (_c, { signal }) => new Promise((_, rej) => signal.addEventListener('abort', () => rej(new Error('aborted')))) }); + const f = await failing(target(hang)); + assert.deepEqual(f, [ALLOW_CASE, 'DENY signs nothing', ...DERIVED]); + }); + + test('an engine that throws fails every case that needs an answer', async () => { + const f = await failing(target(() => engine({ evaluate: async () => { throw new Error('boom'); } }))); + assert.deepEqual(f, [ALLOW_CASE, 'DENY signs nothing', ...DERIVED]); + }); + + test('an engine whose ALLOW is already expired fails the ALLOW case', async () => { + const f = await failing(target(() => engine({}, (_c, d) => d.decision === 'ALLOW' ? { ...d, expiresAt: Date.now() - 1 } : d))); + assert.ok(f.includes(ALLOW_CASE), f.join('; ')); + }); + + test('an engine with requireExpiry and no expiresAt fails the ALLOW case', async () => { + const f = await failing(target(() => engine({}, (_c, d) => d.decision === 'ALLOW' ? { ...d, expiresAt: undefined } : d))); + assert.ok(f.includes(ALLOW_CASE), f.join('; ')); + }); + + test('an engine whose ALLOW carries a stale policyVersion fails the ALLOW case', async () => { + const f = await failing(target(() => engine({}, (_c, d) => d.decision === 'ALLOW' ? { ...d, policyVersion: 'v0' } : d))); + assert.ok(f.includes(ALLOW_CASE), f.join('; ')); + }); + + test('an engine that returns a malformed verdict fails the ALLOW case', async () => { + const f = await failing(target(() => engine({}, (_c, d) => (d.decision === 'ALLOW' ? { ...d, decision: 'yes' } : d) as X402PolicyDecision))); + assert.ok(f.includes(ALLOW_CASE), f.join('; ')); + }); + + test('a gate that signs whatever the engine says fails every refusal case', async () => { + const leaky: typeof createPolicyGatedSigner = (base, policy, getCall, onRefused) => { + const real = createPolicyGatedSigner(base, policy, getCall, onRefused); + return { address: base.address, signAuthEntry: async (x, o) => { + try { return await real.signAuthEntry(x, o); } catch { return base.signAuthEntry(x, o); } + } }; + }; + const f = await failing(target(() => engine()), { gate: leaky }); + assert.deepEqual(f, ALL.slice(1)); + }); + + test('a gate that never signs fails the ALLOW case', async () => { + const closed: typeof createPolicyGatedSigner = (base) => ({ address: base.address, signAuthEntry: async () => { throw new Error('closed'); } }); + const f = await failing(target(() => engine()), { gate: closed }); + assert.ok(f.includes(ALLOW_CASE), f.join('; ')); + }); + + test('createHook is called once per case', async () => { + let n = 0; + await failing(target(() => { n++; return engine(); })); + assert.equal(n, ALL.length); + }); + // Review on #246: an engine slower than the timeout case's deadline is still correct. + for (const ms of [200, 600]) { + test(`a correct engine that takes ${ms} ms (e.g. remote HTTP) passes every case`, async () => { + const slow = () => engine({}, undefined); + const t = target(() => { + const e = slow(); + const inner = e.evaluate; + return { ...e, evaluate: async (ctx, o) => { await new Promise((r) => setTimeout(r, ms)); return inner(ctx, o); } }; + }); + assert.deepEqual(await failing(t), []); + }); + } + + // Review on #246: class-based engines keep prototype methods and `this`. + class ClassEngine implements X402PolicyHook { + requireExpiry = true; + version = 'v1'; + answerVersion = 'v1'; + seen: string[] = []; + async currentVersion() { return this.version; } + async evaluate(ctx: X402PolicyContext): Promise { + if (ctx.authorization.to === blocked) return { decision: 'WAIT', reason: 'needs a human' }; + if (BigInt(ctx.authorization.amount) > 1_000_000n) return { decision: 'DENY', reason: 'over cap' }; + return { decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: this.answerVersion, expiresAt: Date.now() + 2_000 }; + } + onDecision(r: { outcome: string }) { this.seen.push(r.outcome); } + } + + test('a correct class-based engine passes every case', async () => { + assert.deepEqual(await failing(target(() => new ClassEngine())), []); + }); + + test('a class-based engine whose ALLOW carries a stale policyVersion fails the ALLOW case', async () => { + const f = await failing(target(() => { const e = new ClassEngine(); e.answerVersion = 'v0'; return e; })); + assert.ok(f.includes(ALLOW_CASE), f.join('; ')); + }); + + test('a class-based onDecision runs with its own `this`', async () => { + const e = new ClassEngine(); + const [allowCase] = policyConformanceCases(target(() => e)); + await allowCase.run(); + assert.deepEqual(e.seen, ['ALLOWED', 'SIGNED']); + }); +});