diff --git a/src/cli/commands/qualify/chaos-handler.ts b/src/cli/commands/qualify/chaos-handler.ts index 4d2e6eb..787bf97 100644 --- a/src/cli/commands/qualify/chaos-handler.ts +++ b/src/cli/commands/qualify/chaos-handler.ts @@ -71,13 +71,13 @@ export async function runChaosOnRoute( for (const phase of Object.values(composed.phases)) { for (const req of phase.requires) { if (!route.requires.includes(req.formula)) { - route.requires.push(req.formula) + route.requires = [...route.requires, req.formula] route.formulaSources[req.formula] = req.source } } for (const ens of phase.ensures) { if (!route.ensures.includes(ens.formula)) { - route.ensures.push(ens.formula) + route.ensures = [...route.ensures, ens.formula] route.formulaSources[ens.formula] = ens.source } } diff --git a/src/cli/commands/verify/runner.ts b/src/cli/commands/verify/runner.ts index 609d7f3..17f0169 100644 --- a/src/cli/commands/verify/runner.ts +++ b/src/cli/commands/verify/runner.ts @@ -527,13 +527,13 @@ export async function runVerify(deps: VerifyRunnerDeps): Promise { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/ping', { + schema: { + 'x-category': 'utility', + 'x-ensures': ['status:200', 'response_body(this).pong == true'], + response: { 200: { type: 'object', properties: { pong: { type: 'boolean' } } } } + } + }, async () => { + return { pong: true } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ + runs: 5, + chaos: { probability: 0 } + }) + assert.strictEqual(result.summary.failed, 0) +}) + +test('chaos: error injection breaks contracts', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/critical', { + schema: { + 'x-category': 'constructor', + 'x-ensures': ['status:201', 'response_body(this).id != null'], + body: { type: 'object', properties: { x: { type: 'number' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' } } } } + } + }, async (req, reply) => { + reply.status(201) + return { id: 'c-1' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ + runs: 10, + chaos: { + probability: 1, + error: { probability: 1, statusCode: 503 } + } + }) + assert.ok(result.summary.failed > 0, 'chaos error injection should cause contract failures') +}) + +test('chaos: createChaosEventArbitrary determinism with fixed seed', async () => { + const arb = createChaosEventArbitrary( + { + probability: 0.5, + error: { probability: 0.5, statusCode: 503 }, + delay: { probability: 0.5, minMs: 0, maxMs: 100 }, + }, + ['test.contract'] + ) + + const events1 = fc.sample(arb, { numRuns: 1, seed: 42 })[0]! + const events2 = fc.sample(arb, { numRuns: 1, seed: 42 })[0]! + assert.deepStrictEqual(events1, events2, 'same seed produces identical chaos events') + + const events3 = fc.sample(arb, { numRuns: 1, seed: 43 })[0]! + assert.deepStrictEqual(events1, events2) +}) + +test('chaos: FlakeDetector classifies deterministic failures as not flaky', async () => { + let callCount = 0 + const rerunFn = async (seed?: number) => { + callCount++ + return { passed: false } + } + + const detector = new FlakeDetector({ sameSeedReruns: 1, seedVariations: 2 }) + const report = await detector.detectFlake( + { ok: false, name: 'test', id: 1 }, + rerunFn, + 42 + ) + assert.strictEqual(report.isFlaky, false) + assert.strictEqual(report.confidence, 'high') +}) + +test('chaos: FlakeDetector with varying seeds detects intermittent failures', async () => { + let flip = false + const rerunFn = async (seed?: number) => { + flip = !flip + return { passed: flip } + } + + const detector = new FlakeDetector({ sameSeedReruns: 1, seedVariations: 3 }) + const report = await detector.detectFlake( + { ok: false, name: 'test', id: 1 }, + rerunFn, + 42 + ) + assert.strictEqual(report.isFlaky, true) +}) + +test('chaos: chaos events with no strategies produce only none events', async () => { + const arb = createChaosEventArbitrary( + { probability: 0.5 }, + ['svc.users'] + ) + const events = fc.sample(arb, { numRuns: 1, seed: 1 })[0]! + assert.strictEqual(events.filter((e: any) => e.type !== 'none').length, 0) +}) + +test('chaos: chaos events with error strategy generates non-none events', async () => { + const arb = createChaosEventArbitrary( + { probability: 1, error: { probability: 1, statusCode: 503 } }, + ['svc.orders'] + ) + const samples = fc.sample(arb, { numRuns: 10, seed: 123 }) + const activeEvents = samples.flat().filter((e: any) => e.type !== 'none') + assert.ok(activeEvents.length > 0, 'error strategy should produce chaos events') +}) + +test('chaos: contract runner with chaos no-ops ignores none events', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/stable', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200', 'response_body(this).stable == true'], + response: { 200: { type: 'object', properties: { stable: { type: 'boolean' } } } } + } + }, async () => { + return { stable: true } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ + runs: 10, + chaos: { probability: 0 } + }) + assert.strictEqual(result.summary.failed, 0) +}) diff --git a/src/test/expressive-contracts.test.ts b/src/test/expressive-contracts.test.ts new file mode 100644 index 0000000..ff9b49f --- /dev/null +++ b/src/test/expressive-contracts.test.ts @@ -0,0 +1,555 @@ +import { describe, test, before, after } from 'node:test' +import assert from 'node:assert' +import Fastify from 'fastify' +import apophisPlugin from '../index.js' + +const FORMULA_EDGE_CASES = describe('expressive contracts: formula edge cases') + +test('if/then/else nesting: status-dependent body check', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off', profile: 'platform-observe' }) + + fastify.post('/order', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'if status:201 then response_body(this).id != null else if status:400 then response_body(this).error != null else true' + ], + body: { type: 'object', properties: { amount: { type: 'number' } } }, + response: { + 201: { type: 'object', properties: { id: { type: 'string' } } }, + 400: { type: 'object', properties: { error: { type: 'string' } } } + } + } + }, async (req, reply) => { + if (req.body.amount > 0) { reply.status(201); return { id: 'ord-1' } } + reply.status(400); return { error: 'amount must be positive' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res1 = await fastify.inject({ method: 'POST', url: '/order', payload: { amount: 100 } }) + assert.strictEqual(res1.statusCode, 201) + assert.ok(res1.json().id) + + const res2 = await fastify.inject({ method: 'POST', url: '/order', payload: { amount: 0 } }) + assert.strictEqual(res2.statusCode, 400) + assert.ok(res2.json().error) +}) + +test('request_headers and request_body accessors', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/echo', { + schema: { + 'x-category': 'mutator', + 'x-ensures': [ + 'response_body(this).echoed == request_body(this).message', + ], + body: { type: 'object', properties: { message: { type: 'string' } } }, + response: { 200: { type: 'object', properties: { echoed: { type: 'string' } } } } + } + }, async (req) => { + return { echoed: req.body.message } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/echo', payload: { message: 'hello world' } }) + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.json().echoed, 'hello world') +}) + +test('response_headers validation: content-type and custom header', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/json', { + schema: { + 'x-category': 'observer', + 'x-ensures': [ + 'response_headers(this).content-type == "application/json; charset=utf-8"', + ], + response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } } + } + }, async (req, reply) => { + reply.header('x-custom', 'foobar') + return { ok: true } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'GET', url: '/json' }) + assert.strictEqual(res.statusCode, 200) + assert.ok(res.json().ok) +}) + +test('comparison operators: ==, !=, >, <, >=, <=', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/score', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'response_body(this).score >= 0', + 'response_body(this).score <= 100', + 'response_body(this).grade != null', + ], + body: { type: 'object', properties: { raw: { type: 'number' } } }, + response: { 201: { type: 'object', properties: { score: { type: 'number' }, grade: { type: 'string' } } } } + } + }, async (req, reply) => { + const score = Math.min(100, Math.max(0, req.body.raw)) + const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : 'F' + reply.status(201) + return { score, grade } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/score', payload: { raw: 85 } }) + assert.strictEqual(res.statusCode, 201) + const body = res.json() + assert.ok(body.score >= 0 && body.score <= 100) + assert.ok(body.grade) +}) + +test('logical operators: and, or, not', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/widget', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'response_body(this).id != null', + 'response_body(this).color == "red" || response_body(this).color == "blue"', + ], + body: { type: 'object', properties: { color: { type: 'string' }, size: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' }, color: { type: 'string' }, size: { type: 'string' } } } } + } + }, async (req, reply) => { + reply.status(201) + return { id: 'w-' + Math.random().toString(36).slice(2), color: req.body.color || 'red', size: req.body.size || 'm' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/widget', payload: { color: 'blue' } }) + assert.strictEqual(res.statusCode, 201) + const body = res.json() + assert.ok(body.id) + assert.ok(body.color === 'red' || body.color === 'blue') +}) + +test('cross-operation with previous(): verify created resource is retrievable', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off', profile: 'protocol-lab' }) + + fastify.post('/projects', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + // Cross-operation: fetch the project we just created and verify it's found + 'previous(response_code(GET /projects/{response_body(this).name})) == 200', + ], + body: { type: 'object', properties: { name: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { name: { type: 'string' } } } } + } + }, async (req, reply) => { + reply.status(201) + return { name: req.body.name } + }) + + fastify.get('/projects/:name', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200', 'response_body(this).name == request_params(this).name'], + response: { 200: { type: 'object', properties: { name: { type: 'string' } } } } + } + }, async (req) => { + return { name: (req.params as any).name } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const createRes = await fastify.inject({ method: 'POST', url: '/projects', payload: { name: 'apophis-v2' } }) + assert.strictEqual(createRes.statusCode, 201) + assert.strictEqual(createRes.json().name, 'apophis-v2') + + const getRes = await fastify.inject({ method: 'GET', url: '/projects/apophis-v2' }) + assert.strictEqual(getRes.statusCode, 200) + assert.strictEqual(getRes.json().name, 'apophis-v2') +}) + +test('complex HATEOAS: linked resource route_exists and compliance', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/tenants', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + 'response_body(this).id != null', + // HATEOAS: self link must exist + 'response_body(this)._links.self.href != null', + ], + body: { type: 'object', properties: { name: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' }, _links: { type: 'object', properties: { self: { type: 'object', properties: { href: { type: 'string' } } } } } } } } + } + }, async (req, reply) => { + const id = 't-' + Math.random().toString(36).slice(2, 8) + reply.status(201) + return { id, name: req.body.name, _links: { self: { href: `/tenants/${id}` } } } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/tenants', payload: { name: 'acme' } }) + assert.strictEqual(res.statusCode, 201) + const body = res.json() + assert.ok(body._links.self.href) + assert.ok(body.id) +}) + +test('null handling: optional fields in response', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/user-simple', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + 'response_body(this).id != null', + ], + body: { type: 'object', properties: { name: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, avatar: { type: 'string' } } } } + } + }, async (req, reply) => { + reply.status(201) + return { id: 'u-1', name: req.body.name } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/user-simple', payload: { name: 'Test User' } }) + assert.strictEqual(res.statusCode, 201) + assert.strictEqual(res.json().id, 'u-1') +}) + +test('api versioning with x-variants: different contracts per version', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/me', { + schema: { + 'x-category': 'observer', + 'x-variants': [ + { name: 'v1', headers: { 'x-api-version': 'v1' } }, + { name: 'v2', headers: { 'x-api-version': 'v2' } } + ], + 'x-ensures': [ + 'status:200', + 'response_body(this).id != null', + ], + response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } } } + } + }, async () => { + return { id: 'me-1', email: 'user@example.com' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const resV1 = await fastify.inject({ method: 'GET', url: '/me', headers: { 'x-api-version': 'v1' } }) + assert.strictEqual(resV1.statusCode, 200) + + const resV2 = await fastify.inject({ method: 'GET', url: '/me', headers: { 'x-api-version': 'v2' } }) + assert.strictEqual(resV2.statusCode, 200) +}) + +test('concatenation and string formulas in ensures', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/concatenate', { + schema: { + 'x-category': 'mutator', + 'x-ensures': [ + 'response_body(this).display == request_body(this).first && response_body(this).display != null', + ], + body: { type: 'object', properties: { first: { type: 'string' }, last: { type: 'string' } } }, + response: { 200: { type: 'object', properties: { display: { type: 'string' } } } } + } + }, async (req) => { + return { display: req.body.first + ' ' + req.body.last } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/concatenate', payload: { first: 'Jane', last: 'Doe' } }) + assert.strictEqual(res.statusCode, 200) + assert.ok(res.json().display) +}) + +test('boolean response field: truthiness in ensures', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/feature-toggle', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + 'response_body(this).enabled != null', + ], + body: { type: 'object', properties: { name: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' }, enabled: { type: 'boolean' } } } } + } + }, async (req, reply) => { + reply.status(201) + return { id: 'ft-1', name: req.body.name, enabled: req.body.name === 'dark-mode' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/feature-toggle', payload: { name: 'dark-mode' } }) + assert.strictEqual(res.statusCode, 201) + assert.strictEqual(res.json().enabled, true) +}) + +test('numeric computations: derived field must match formula', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/invoice-line', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + 'response_body(this).line_total != null', + ], + body: { type: 'object', properties: { quantity: { type: 'number' }, unit_price: { type: 'number' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' }, line_total: { type: 'number' } } } } + } + }, async (req, reply) => { + reply.status(201) + const total = req.body.quantity * req.body.unit_price + return { id: 'inv-1', line_total: total } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/invoice-line', payload: { quantity: 3, unit_price: 49.99 } }) + assert.strictEqual(res.statusCode, 201) + const body = res.json() + assert.ok(Math.abs(body.line_total - 149.97) < 0.01) +}) + +test('guard conditions: special characters and unicode in strings', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/unicode', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + 'response_body(this).greeting != null', + ], + body: { type: 'object', properties: { lang: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' }, greeting: { type: 'string' } } } } + } + }, async (req, reply) => { + reply.status(201) + const greetings: Record = { + ja: '\u3053\u3093\u306b\u3061\u306f', + ar: '\u0645\u0631\u062d\u0628\u0627', + en: 'Hello \u2600' + } + return { id: 'u-1', greeting: greetings[req.body.lang] || greetings.en } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const jaRes = await fastify.inject({ method: 'POST', url: '/unicode', payload: { lang: 'ja' } }) + assert.strictEqual(jaRes.statusCode, 201) + assert.strictEqual(jaRes.json().greeting, '\u3053\u3093\u306b\u3061\u306f') + + const arRes = await fastify.inject({ method: 'POST', url: '/unicode', payload: { lang: 'ar' } }) + assert.strictEqual(arRes.statusCode, 201) + assert.strictEqual(arRes.json().greeting, '\u0645\u0631\u062d\u0628\u0627') +}) + +test('large response body: deep nested object with many keys', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/large', { + schema: { + 'x-category': 'constructor', + 'x-ensures': [ + 'status:201', + 'response_body(this).total_items != null', + 'response_body(this).items != null', + ], + body: { type: 'object', properties: { count: { type: 'number' } } }, + response: { 201: { type: 'object', properties: { total_items: { type: 'number' }, items: { type: 'array' } } } } + } + }, async (req, reply) => { + reply.status(201) + const count = Math.min(req.body.count, 100) + const items = Array.from({ length: count }, (_, i) => ({ + index: i, name: `Item ${i}`, nested: { a: i, b: i * 2, c: { d: `deep-${i}` } } + })) + return { total_items: count, items } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'POST', url: '/large', payload: { count: 50 } }) + assert.strictEqual(res.statusCode, 201) + const body = res.json() + assert.strictEqual(body.total_items, 50) + assert.strictEqual(body.items.length, 50) + assert.strictEqual(body.items[0].nested.c.d, 'deep-0') +}) + +test('verification: runVerify surfaces failures from broken contracts', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.post('/broken', { + schema: { + 'x-category': 'constructor', + 'x-ensures': ['status:201', 'response_body(this).id != null'], + body: { type: 'object', properties: { name: { type: 'string' } } }, + response: { 201: { type: 'object', properties: { id: { type: 'string' } } } } + } + }, async (req, reply) => { + reply.status(200) + return { name: req.body.name } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + + // Should detect status:201 failure + assert.ok(result.summary.failed > 0, 'expected at least one failure') +}) + +test('verification: passing contracts produce zero failures', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/ok', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200', 'response_body(this).ok == true'], + response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } } + } + }, async () => { + return { ok: true } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + + assert.strictEqual(result.summary.failed, 0, 'expected zero failures for passing contracts') +}) + +test('empty body: ensuring no body on 204', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.delete('/cleanup', { + schema: { + 'x-category': 'mutator', + 'x-ensures': ['status:204'], + response: { 204: { type: 'null' } } + } + }, async (req, reply) => { + reply.status(204).send() + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'DELETE', url: '/cleanup' }) + assert.strictEqual(res.statusCode, 204) +}) + +test('array response: list endpoint with required fields', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/list', { + schema: { + 'x-category': 'observer', + 'x-ensures': [ + 'status:200', + 'response_body(this).items != null', + ], + response: { 200: { type: 'object', properties: { count: { type: 'number' }, items: { type: 'array' } } } } + } + }, async () => { + return { count: 3, items: [{ a: 1 }, { a: 2 }, { a: 3 }] } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'GET', url: '/list' }) + assert.strictEqual(res.statusCode, 200) + const body = res.json() + assert.strictEqual(body.count, 3) + assert.strictEqual(body.items.length, 3) +}) + +test('authorization preconditions: x-requires guards', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { runtime: 'off' }) + + fastify.get('/admin/dashboard', { + schema: { + 'x-category': 'observer', + 'x-requires': ['auth_admin'], + 'x-ensures': ['status:200', 'response_body(this).admin_panels != null'], + response: { + 200: { type: 'object', properties: { admin_panels: { type: 'array' } } }, + 403: { type: 'object', properties: { error: { type: 'string' } } } + } + } + }, async () => { + return { admin_panels: ['users', 'analytics', 'billing'] } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const res = await fastify.inject({ method: 'GET', url: '/admin/dashboard' }) + assert.strictEqual(res.statusCode, 200) + assert.ok(Array.isArray(res.json().admin_panels)) +}) diff --git a/src/test/outbound-final.test.ts b/src/test/outbound-final.test.ts new file mode 100644 index 0000000..f185c4a --- /dev/null +++ b/src/test/outbound-final.test.ts @@ -0,0 +1,242 @@ +import { test } from 'node:test' +import assert from 'node:assert' +import Fastify from 'fastify' +import apophisPlugin from '../index.js' +import { createOutboundMockRuntime } from '../infrastructure/outbound-mock-runtime.js' +import { OutboundContractRegistry } from '../domain/outbound-contracts.js' + +test('outbound mock: registry resolves multiple contracts', () => { + const registry = new OutboundContractRegistry() + registry.register('svc.users', { + target: 'https://api.example.com/users', + method: 'GET', + response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } } } + }) + registry.register('svc.orders', { + target: 'https://api.example.com/orders', + method: 'POST', + response: { 201: { type: 'object', properties: { id: { type: 'string' }, total: { type: 'number' } } } } + }) + + const resolved = registry.resolve([ + 'svc.users', + 'svc.orders', + { name: 'svc.payments', target: 'https://pay.example.com', method: 'POST', response: { 200: { type: 'object', properties: { status: { type: 'string' } } } } } + ]) + assert.strictEqual(resolved.length, 3) +}) + +test('outbound mock: multiple concurrent installs are isolated', async () => { + const runtime1 = createOutboundMockRuntime({ + contracts: [{ + name: 'svc.a', + target: 'https://a.example.com', + method: 'GET', + response: { 200: { type: 'object', properties: { source: { type: 'string' } } } } + }], + mode: 'example', + unmatched: 'passthrough', + seed: 1, + overrides: { 'svc.a': { forceStatus: 200, body: { source: 'runtime-1' } } } + }) + + const runtime2 = createOutboundMockRuntime({ + contracts: [{ + name: 'svc.a', + target: 'https://a.example.com', + method: 'GET', + response: { 200: { type: 'object', properties: { source: { type: 'string' } } } } + }], + mode: 'example', + unmatched: 'passthrough', + seed: 2, + overrides: { 'svc.a': { forceStatus: 200, body: { source: 'runtime-2' } } } + }) + + runtime1.install() + runtime2.install() + runtime2.restore() + runtime1.restore() + runtime1.restore() +}) + +test('outbound mock: records multiple calls to same contract', async () => { + const runtime = createOutboundMockRuntime({ + contracts: [{ + name: 'counter', + target: 'https://counter.example.com/tick', + method: 'POST', + response: { 200: { type: 'object', properties: { count: { type: 'number' } } } } + }], + mode: 'example', + unmatched: 'error', + seed: 42, + }) + + runtime.install() + await globalThis.fetch('https://counter.example.com/tick', { method: 'POST', body: JSON.stringify({}) }) + await globalThis.fetch('https://counter.example.com/tick', { method: 'POST', body: JSON.stringify({}) }) + + const calls = runtime.getCalls('counter') + assert.strictEqual(calls.length, 2) + runtime.restore() +}) + +test('outbound mock: stateful resource lifecycle through mock', async (t) => { + const runtime = createOutboundMockRuntime({ + contracts: [{ + name: 'resources', + target: 'https://res.example.com/v1', + method: '*', + response: { + 201: { type: 'object', properties: { id: { type: 'string' }, data: { type: 'string' } } }, + 200: { type: 'object', properties: { id: { type: 'string' }, data: { type: 'string' } } } + }, + resource: { + idField: 'id', + idPattern: '/v1/:id', + createMethods: ['POST'], + readMethods: ['GET'], + updateMethods: ['PATCH'], + deleteMethods: ['DELETE'], + } + }], + mode: 'example', + unmatched: 'error', + seed: 42, + }) + + runtime.install() + + const createRes = await globalThis.fetch('https://res.example.com/v1', { + method: 'POST', + body: JSON.stringify({ data: 'hello' }), + headers: { 'content-type': 'application/json' } + }) + assert.strictEqual(createRes.status, 201) + const created = await createRes.json() + assert.ok(created.id) + + const getRes = await globalThis.fetch(`https://res.example.com/v1/${created.id}`) + assert.strictEqual(getRes.status, 200) + + const deleteRes = await globalThis.fetch(`https://res.example.com/v1/${created.id}`, { method: 'DELETE' }) + assert.strictEqual(deleteRes.status, 200) + + const afterDel = await globalThis.fetch(`https://res.example.com/v1/${created.id}`) + assert.notStrictEqual(afterDel.status, 200) + + runtime.restore() +}) + +test('outbound mock: overrides change status and body', async () => { + const runtime = createOutboundMockRuntime({ + contracts: [{ + name: 'payments', + target: 'https://pay.example.com/charge', + method: 'POST', + response: { 201: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string' } } } } + }], + mode: 'example', + unmatched: 'error', + seed: 42, + overrides: { + 'payments': { + forceStatus: 402, + body: { error: 'insufficient_funds', code: 'PAY-402' } + } + } + }) + + runtime.install() + + const res = await globalThis.fetch('https://pay.example.com/charge', { + method: 'POST', + body: JSON.stringify({ amount: 999 }), + headers: { 'content-type': 'application/json' } + }) + assert.strictEqual(res.status, 402) + const body = await res.json() + assert.strictEqual(body.code, 'PAY-402') + assert.strictEqual(body.error, 'insufficient_funds') + + runtime.restore() +}) + +test('outbound mock: unmatched passthrough (no real HTTP attempt)', async () => { + const runtime = createOutboundMockRuntime({ + contracts: [], + mode: 'example', + unmatched: 'passthrough', + seed: 42, + }) + + runtime.install() + + let threw = false + try { + await globalThis.fetch('https://unknown.example.com/anything') + } catch (e) { + threw = true + } + runtime.restore() +}) + +test('outbound mock: clear resets in-memory resource state', async () => { + const runtime = createOutboundMockRuntime({ + contracts: [{ + name: 'cacheable', + target: 'https://cache.example.com/v1', + method: '*', + response: { + 201: { type: 'object', properties: { id: { type: 'string' } } }, + 200: { type: 'object', properties: { id: { type: 'string' } } } + }, + resource: { + idField: 'id', + idPattern: '/v1/:id', + createMethods: ['POST'], + readMethods: ['GET'], + } + }], + mode: 'example', + unmatched: 'error', + seed: 42, + }) + + runtime.install() + + const createRes = await globalThis.fetch('https://cache.example.com/v1', { method: 'POST', body: JSON.stringify({}) }) + const { id } = await createRes.json() + const getRes = await globalThis.fetch(`https://cache.example.com/v1/${id}`) + assert.strictEqual(getRes.status, 200) + + runtime.clear() + + const afterClear = await globalThis.fetch(`https://cache.example.com/v1/${id}`) + assert.notStrictEqual(afterClear.status, 200) + + runtime.restore() +}) + +test('outbound mock: install/restore/install cycle works', async () => { + const runtime = createOutboundMockRuntime({ + contracts: [{ + name: 'toggle', + target: 'https://toggle.example.com/status', + method: 'GET', + response: { 200: { type: 'object', properties: { active: { type: 'boolean' } } } } + }], + mode: 'example', + unmatched: 'error', + seed: 42, + }) + + runtime.install() + assert.strictEqual((await globalThis.fetch('https://toggle.example.com/status')).status, 200) + runtime.restore() + + runtime.install() + assert.strictEqual((await globalThis.fetch('https://toggle.example.com/status')).status, 200) + runtime.restore() +}) diff --git a/src/test/plugin-contracts.test.ts b/src/test/plugin-contracts.test.ts new file mode 100644 index 0000000..d0bca73 --- /dev/null +++ b/src/test/plugin-contracts.test.ts @@ -0,0 +1,404 @@ +import { test } from 'node:test' +import assert from 'node:assert' +import Fastify from 'fastify' +import apophisPlugin from '../index.js' + +test('plugin contracts: formula composition with passing ensures', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'security-plugin': { + appliesTo: '/api/**', + hooks: { + preHandler: { ensures: ['status:200'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['response_body(this).ok == true'], + response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } } + } + }, async () => { + return { ok: true } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0, 'plugin ensures should pass') + assert.ok(result.summary.pluginContractsApplied > 0, 'should apply plugin contracts') +}) + +test('plugin contracts: plugin ensures failure detected', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'always-fail': { + appliesTo: '/api/**', + hooks: { + preHandler: { ensures: ['1 == 2'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { val: { type: 'string' } } } } + } + }, async () => { + return { val: 'abc' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.ok(result.summary.failed > 0, 'plugin ensures should fail') + assert.ok(result.summary.pluginContractsFailed > 0, 'should report plugin contract failures') +}) + +test('plugin contracts: missing required extension silently skips plugin', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'missing-ext': { + appliesTo: '/api/**', + extensions: [{ name: 'nonexistent', required: true }], + hooks: { + preHandler: { ensures: ['1 == 2'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { val: { type: 'string' } } } } + } + }, async () => { + return { val: 'abc' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0) + assert.strictEqual(result.summary.pluginContractsFailed ?? 0, 0, 'no plugin failures when plugin is skipped') +}) + +test('plugin contracts: extension predicate passes when condition met', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + extensions: [ + { + name: 'tenant-gate', + headers: ['check_tenant'], + predicates: { + check_tenant: (ctx: { evalContext: { request: { headers: Record } } }) => ({ + value: ctx.evalContext.request.headers['x-tenant'] === 'acme', + success: true, + }), + }, + }, + ], + pluginContracts: { + 'tenant-validator': { + appliesTo: '/api/**', + extensions: [{ name: 'tenant-gate', required: true }], + hooks: { + onRequest: { requires: ['check_tenant(this) == true'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { val: { type: 'string' } } } } + } + }, async () => { + return { val: 'acme-data' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ + runs: 10, + variants: [{ name: 'acme', headers: { 'x-tenant': 'acme' } }], + }) + assert.strictEqual(result.summary.failed, 0, 'extension predicate should pass') +}) + +test('plugin contracts: multiple plugins composing on same route', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'auth-check': { + appliesTo: '/api/**', + hooks: { + onRequest: { requires: ['true'] } + } + }, + 'rate-limit': { + appliesTo: '/api/**', + hooks: { + preHandler: { ensures: ['status:200'] } + } + }, + 'logging': { + appliesTo: '/api/**', + hooks: { + onSend: { ensures: ['response_body(this).logged == true'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { logged: { type: 'boolean' }, val: { type: 'string' } } } } + } + }, async () => { + return { logged: true, val: 'data' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0, 'all plugins should pass') + assert.ok(result.summary.pluginContractsApplied >= 3, 'all plugins should be applied') +}) + +test('plugin contracts: appliesTo glob scoping — only matching routes', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'admin-only': { + appliesTo: '/admin/**', + hooks: { + preHandler: { ensures: ['1 == 1'] } + } + } + } + }) + + fastify.get('/public/health', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } } + } + }, async () => { + return { ok: true } + }) + + fastify.get('/admin/users', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200', 'response_body(this).admin_data == true'], + response: { 200: { type: 'object', properties: { admin_data: { type: 'boolean' } } } } + } + }, async () => { + return { admin_data: true } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0) + assert.ok(result.summary.pluginContractsApplied > 0) +}) + +test('plugin contracts: onSend hook validates response body after handler', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'response-validator': { + appliesTo: '/api/**', + hooks: { + onSend: { ensures: ['response_body(this).received_at != null'] } + } + } + } + }) + + fastify.get('/api/events', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { events: { type: 'array' }, received_at: { type: 'string' } } } } + } + }, async () => { + return { events: [], received_at: new Date().toISOString() } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0) +}) + +test('plugin contracts: plugin with non-required extension passes when extension absent', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'optional-validator': { + appliesTo: '/api/**', + extensions: [{ name: 'optional-ext', required: false }], + hooks: { + preHandler: { ensures: ['status:200'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['response_body(this).val != null'], + response: { 200: { type: 'object', properties: { val: { type: 'string' } } } } + } + }, async () => { + return { val: 'ok' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0, 'non-required extension absence should not block plugin') + assert.ok(result.summary.pluginContractsApplied > 0) +}) + +test('plugin contracts: registry warnings for missing extensions', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'warn-about-me': { + appliesTo: '/api/**', + extensions: [{ name: 'nonexistent-warn', required: true }], + hooks: { + preHandler: { ensures: ['status:200'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['response_body(this).val != null'], + response: { 200: { type: 'object', properties: { val: { type: 'string' } } } } + } + }, async () => { + return { val: 'ok' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const reg = (fastify as any)._apophisPluginContractRegistry + assert.ok(reg, 'plugin contract registry should exist') + // After plugin processing, warnings should exist + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0) +}) + +test('plugin contracts: route-level and plugin-level ensures both enforced', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'core-policy': { + appliesTo: '/api/**', + hooks: { + preHandler: { ensures: ['status:200'] } + } + } + } + }) + + fastify.get('/api/double', { + schema: { + 'x-category': 'observer', + 'x-ensures': [ + 'status:200', + 'response_body(this).route_level == true', + ], + response: { 200: { type: 'object', properties: { route_level: { type: 'boolean' }, val: { type: 'string' } } } } + } + }, async () => { + return { route_level: true, val: 'double-checked' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.strictEqual(result.summary.failed, 0, 'both route and plugin ensures should pass') + assert.ok(result.summary.pluginContractsApplied > 0) +}) + +test('plugin contracts: breaking plugin ensures produces source attribution', async (t) => { + const fastify = Fastify() + await fastify.register(apophisPlugin, { + runtime: 'off', + pluginContracts: { + 'broken-policy': { + appliesTo: '/api/**', + hooks: { + preHandler: { ensures: ['status:500'] } + } + } + } + }) + + fastify.get('/api/data', { + schema: { + 'x-category': 'observer', + 'x-ensures': ['status:200'], + response: { 200: { type: 'object', properties: { val: { type: 'string' } } } } + } + }, async () => { + return { val: 'ok' } + }) + + await fastify.ready() + t.after(() => fastify.close()) + + const result = await (fastify as any).apophis.contract({ runs: 10 }) + assert.ok(result.summary.failed > 0, 'plugin ensures mismatch should fail') + // Plugin-contract-sourced failures should appear + const pluginFailure = result.tests.find((t: any) => + !t.ok && t.diagnostics?.violation?.source?.startsWith('plugin:') + ) + assert.ok(pluginFailure, 'should have plugin-sourced failure with source field') +})