@openwop/openwop-conformance 2.36.1 → 2.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +4 -3
- package/coverage.md +1 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +510 -73
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +27 -27
- package/src/lib/a2a-error-info.ts +44 -0
- package/src/lib/a2a-fake-peer.ts +50 -7
- package/src/lib/effect-receiver.ts +135 -0
- package/src/lib/front-mux.ts +102 -0
- package/src/lib/mcp-fake-server.ts +15 -4
- package/src/lib/oidc-issuer.ts +15 -5
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-durability-recovery.test.ts +83 -35
- package/src/scenarios/v2-idempotency-in-flight.test.ts +132 -0
- package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
- package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
|
@@ -44,14 +44,13 @@
|
|
|
44
44
|
* @see spec/v2/core/versioning.md §1.2
|
|
45
45
|
*/
|
|
46
46
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
47
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
48
47
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
49
48
|
import { join } from 'node:path';
|
|
50
49
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
51
50
|
import addFormats from 'ajv-formats';
|
|
52
51
|
import { driver } from '../lib/driver.js';
|
|
53
52
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
54
|
-
import {
|
|
53
|
+
import { noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
55
54
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
56
55
|
import { softSkip } from '../lib/soft-skip.js';
|
|
57
56
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -67,32 +66,23 @@ const EVENT_TYPE = 'run.started';
|
|
|
67
66
|
type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
|
|
68
67
|
type Validator = (doc: unknown) => { ok: boolean; errors: string };
|
|
69
68
|
|
|
70
|
-
async function startReceiver(): Promise<
|
|
69
|
+
async function startReceiver(): Promise<ScopedReceiver & { deliveries: Delivery[] }> {
|
|
71
70
|
const deliveries: Delivery[] = [];
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
request.on('end', () => {
|
|
76
|
-
deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers });
|
|
77
|
-
res.writeHead(204); res.end();
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
// Honour OPENWOP_WEBHOOK_RECEIVER_PORT like `v2-webhook-durable-delivery`
|
|
81
|
-
// does. Without it, a tunnelled run registers the tunnel URL here and the
|
|
71
|
+
// `startScopedReceiver` (2.37.0) replaces this file's own `createServer` +
|
|
72
|
+
// pinned-port binding. The comment that stood here described the collision
|
|
73
|
+
// from the inside: "a tunnelled run registers the tunnel URL here and the
|
|
82
74
|
// tunnel forwards to the PINNED port — held by the other receiver, which
|
|
83
75
|
// answers 500 by design — so this file's `deliveries` stays empty, its legs
|
|
84
76
|
// soft-skip, and (because `register()` already asserted) the rows resolve
|
|
85
|
-
// `executed-pass`.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
95
|
-
return { server, url: `http://${binding.advertise}:${port}/hook`, deliveries };
|
|
77
|
+
// `executed-pass`." Four files registered that one byte-identical URL. Each
|
|
78
|
+
// now registers its own nonce path, and `front-mux` routes a delivery to the
|
|
79
|
+
// exercise it was addressed to whichever listener holds the port.
|
|
80
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
81
|
+
deliveries.push({ body: hit.body, headers: hit.headers });
|
|
82
|
+
res.writeHead(204);
|
|
83
|
+
res.end();
|
|
84
|
+
});
|
|
85
|
+
return { ...rx, deliveries };
|
|
96
86
|
}
|
|
97
87
|
|
|
98
88
|
/**
|
|
@@ -103,8 +93,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; deliverie
|
|
|
103
93
|
* for leg 2, not a failure: there is no v1 wire to keep still.
|
|
104
94
|
*/
|
|
105
95
|
async function register(url: string, major: 1 | 2): Promise<string | null> {
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
// `url` is already the destination for THIS exercise — the public front (when
|
|
97
|
+
// one is wired) plus this receiver's nonce path. It is no longer run through
|
|
98
|
+
// `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
|
|
99
|
+
// the path that makes the subscription this exercise's.
|
|
100
|
+
const reg = await driver.post(major === 2 ? '/webhooks' : '/v1/webhooks', { url, events: [EVENT_TYPE] }, { headers: { 'OpenWOP-Version': major === 2 ? '2.0' : '1.0' } });
|
|
108
101
|
if (major === 1 && reg.status === 404) {
|
|
109
102
|
softSkip('inapplicable', 'host serves no 1.x webhook surface (POST /v1/webhooks not_found) — no v1 wire to keep still');
|
|
110
103
|
return null;
|
|
@@ -214,18 +207,22 @@ const V2 = 'https://openwop.dev/spec/v2/';
|
|
|
214
207
|
const V1 = 'https://openwop.dev/spec/v1/';
|
|
215
208
|
|
|
216
209
|
describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
|
|
217
|
-
|
|
218
|
-
|
|
210
|
+
// Closed through the receiver, not the raw server: `close()` also drops this
|
|
211
|
+
// exercise's nonce from the front-mux registry, so a retry that arrives after
|
|
212
|
+
// the leg has finished is answered 404 by whoever holds the port rather than
|
|
213
|
+
// being handed to the next exercise's recorder.
|
|
214
|
+
let active: ScopedReceiver | null = null;
|
|
215
|
+
afterEach(async () => { await unregisterAll(); const rx = active; active = null; if (rx) await rx.close(); });
|
|
219
216
|
|
|
220
217
|
it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
|
|
221
218
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
222
219
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
223
|
-
const receiver = await startReceiver(); active = receiver
|
|
220
|
+
const receiver = await startReceiver(); active = receiver;
|
|
224
221
|
const webhookId = await register(receiver.url, 2);
|
|
225
222
|
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
226
223
|
const runId = await driveRun();
|
|
227
224
|
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
228
|
-
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
|
|
225
|
+
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's. ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
|
|
229
226
|
const v2 = validators(2);
|
|
230
227
|
const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
|
|
231
228
|
expect(envelope.ok, req(ID, DOC, `a major-2 delivery MUST validate against webhook-delivery.schema.json (v2) — { runId, workspaceId?, event } with event the verbatim v2 run event. ${envelope.errors}`)).toBe(true);
|
|
@@ -245,12 +242,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
245
242
|
// `versions.supported` that no discovery document has, so this leg was inapplicable on every host.
|
|
246
243
|
const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
|
|
247
244
|
if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
|
|
248
|
-
const receiver = await startReceiver(); active = receiver
|
|
245
|
+
const receiver = await startReceiver(); active = receiver;
|
|
249
246
|
const webhookId = await register(receiver.url, 1);
|
|
250
247
|
if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
|
|
251
248
|
const runId = await driveRun();
|
|
252
249
|
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
253
|
-
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
|
|
250
|
+
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s. ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
|
|
254
251
|
// The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
|
|
255
252
|
// §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
|
|
256
253
|
// What the v1 wire cannot carry is the v2 payload's integer `engineVersion` (string on v1) — a fan-out
|
|
@@ -269,7 +266,7 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
269
266
|
// read null as absence, so this leg was inapplicable on exactly the hosts that could witness it.
|
|
270
267
|
const gate = era2Gate(disc);
|
|
271
268
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
272
|
-
const receiver = await startReceiver(); active = receiver
|
|
269
|
+
const receiver = await startReceiver(); active = receiver;
|
|
273
270
|
const webhookId = await register(receiver.url, 2);
|
|
274
271
|
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
275
272
|
const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
|
|
@@ -279,7 +276,7 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
279
276
|
// The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
|
|
280
277
|
// reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
|
|
281
278
|
// era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
|
|
282
|
-
if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance)`);
|
|
279
|
+
if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance). ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
|
|
283
280
|
const payload = validators(2).ref(`${V2}run-event-payloads.schema.json#/$defs/runStarted`)(d.event['payload']);
|
|
284
281
|
expect(payload.ok, req(ID, DOC, `an era-2 row delivered to a major-2 subscriber MUST be projected — the read projection applies at the fan-out as at poll/SSE (events.md §Era-2). ${payload.errors}`)).toBe(true);
|
|
285
282
|
const owner = ownerOf(d.event);
|
|
@@ -28,11 +28,10 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
31
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
32
31
|
import { driver } from '../lib/driver.js';
|
|
33
32
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
34
33
|
import { projectBoundId } from '../lib/bound-id.js';
|
|
35
|
-
import {
|
|
34
|
+
import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
36
35
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
36
|
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
38
37
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -48,47 +47,48 @@ interface Attempt { readonly key: string; readonly runId: string | null; readonl
|
|
|
48
47
|
* (`Infinity` ⇒ always fails). The key is `(webhookId, runId, sequence)` —
|
|
49
48
|
* the dedup triple webhooks.md §Verification names.
|
|
50
49
|
*/
|
|
51
|
-
async function startReceiver(failFirst: number): Promise<
|
|
50
|
+
async function startReceiver(failFirst: number): Promise<ScopedReceiver & { attempts: Attempt[] }> {
|
|
52
51
|
const attempts: Attempt[] = [];
|
|
53
52
|
const seen = new Map<string, number>();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
53
|
+
// `startScopedReceiver` (2.37.0). This receiver answers 500 BY DESIGN, and
|
|
54
|
+
// until now it advertised the same byte-identical destination as the other
|
|
55
|
+
// three webhook files on a tunnelled cut — so on a shared pinned port the
|
|
56
|
+
// exercise that happened to register alongside it saw failures it never
|
|
57
|
+
// caused. The `ours()` filter below was the workaround; the nonce removes the
|
|
58
|
+
// cause. The measured case is in that filter's own comment: a tier-2 host
|
|
59
|
+
// counted 6 attempts against a maxAttempts of 5 because another scenario's
|
|
60
|
+
// subscription delivered into this budget.
|
|
61
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
62
|
+
let runId: string | null = null;
|
|
63
|
+
let sequence: unknown = null;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(hit.body) as { runId?: unknown; event?: { sequence?: unknown } };
|
|
66
|
+
runId = typeof parsed.runId === 'string' ? parsed.runId : null;
|
|
67
|
+
sequence = parsed.event?.sequence ?? null;
|
|
68
|
+
} catch { /* not JSON — still an attempt */ }
|
|
69
|
+
const h = hit.headers;
|
|
70
|
+
const webhookId = String(h['openwop-webhook-id'] ?? h['x-openwop-webhook-id'] ?? '');
|
|
71
|
+
const key = `${webhookId}|${runId ?? ''}|${String(sequence)}`;
|
|
72
|
+
const n = (seen.get(key) ?? 0) + 1;
|
|
73
|
+
seen.set(key, n);
|
|
74
|
+
const status = n <= failFirst ? 500 : 204;
|
|
75
|
+
attempts.push({ key, runId, webhookId, status, at: Date.now() });
|
|
76
|
+
res.writeHead(status);
|
|
77
|
+
res.end();
|
|
76
78
|
});
|
|
77
|
-
|
|
78
|
-
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
79
|
-
const binding = receiverBinding();
|
|
80
|
-
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
81
|
-
const addr = server.address();
|
|
82
|
-
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
83
|
-
return { server, url: `http://${binding.advertise}:${addr.port}/`, attempts };
|
|
79
|
+
return { ...rx, attempts };
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
|
|
82
|
+
// Closed through the receiver: `close()` also drops this exercise's nonce from
|
|
83
|
+
// the front-mux registry, so the retries this file deliberately provokes are
|
|
84
|
+
// answered 404 by whoever next holds the port instead of being handed to the
|
|
85
|
+
// next exercise's recorder.
|
|
86
|
+
let active: ScopedReceiver | null = null;
|
|
87
87
|
afterEach(async () => {
|
|
88
88
|
if (active) {
|
|
89
|
-
const
|
|
89
|
+
const rx = active;
|
|
90
90
|
active = null;
|
|
91
|
-
await
|
|
91
|
+
await rx.close();
|
|
92
92
|
}
|
|
93
93
|
});
|
|
94
94
|
|
|
@@ -215,15 +215,18 @@ const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
|
|
|
215
215
|
const DEAD_LETTER_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS * 2 + WAIT_SLACK_MS;
|
|
216
216
|
|
|
217
217
|
/** Register the suite receiver; null (with a note) when the host's SSRF guard refuses a loopback URL. */
|
|
218
|
-
async function register(
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
async function register(rx: ScopedReceiver): Promise<{ webhookId: string } | null> {
|
|
219
|
+
// `rx.url` is this exercise's own destination already — the front (when
|
|
220
|
+
// wired) plus this receiver's nonce path. It is no longer run through
|
|
221
|
+
// `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
|
|
222
|
+
// the path that makes the subscription ours.
|
|
223
|
+
const reg = await driver.post('/webhooks', { url: rx.url, events: ['run.completed'] });
|
|
221
224
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
222
|
-
if (!
|
|
225
|
+
if (!rx.tunnelled) {
|
|
223
226
|
softSkip('blocked', 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of the suite receiver');
|
|
224
227
|
return null;
|
|
225
228
|
}
|
|
226
|
-
expect.fail(`host rejected the operator-supplied public https receiver (${
|
|
229
|
+
expect.fail(`host rejected the operator-supplied public https receiver (${rx.url}) with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress`);
|
|
227
230
|
}
|
|
228
231
|
expect(reg.status, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
|
|
229
232
|
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
@@ -239,8 +242,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
239
242
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
240
243
|
|
|
241
244
|
const receiver = await startReceiver(FAIL_FIRST); // 500, 500, then 204
|
|
242
|
-
active = receiver
|
|
243
|
-
const sub = await register(receiver
|
|
245
|
+
active = receiver;
|
|
246
|
+
const sub = await register(receiver);
|
|
244
247
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
245
248
|
|
|
246
249
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
@@ -258,9 +261,22 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
258
261
|
const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
|
|
259
262
|
const retried = await waitFor(() => ours().some((a) => a.status === 204), retryWaitMs(doc));
|
|
260
263
|
const attempts = ours();
|
|
264
|
+
// A zero that is PROVABLY not a verdict about the host records `blocked`
|
|
265
|
+
// with its cause, not `executed-fail` (2.37.0). `absenceIsUnmeasured` is
|
|
266
|
+
// true only when other traffic reached this listener — the path from the
|
|
267
|
+
// host to this process works, so what is absent is this exercise's
|
|
268
|
+
// IDENTITY, not delivery. That was the ordinary case on a tunnelled cut
|
|
269
|
+
// until this file stopped sharing one byte-identical destination with three
|
|
270
|
+
// others. `blocked` denies certification exactly as a failure does (RFC
|
|
271
|
+
// 0168 §E.1), so nothing is softened. When nothing reached the listener at
|
|
272
|
+
// all the reading is still ambiguous, and the hard assertion below stands —
|
|
273
|
+
// now carrying the address it was waiting on.
|
|
274
|
+
if (attempts.length === 0 && absenceIsUnmeasured(receiver)) {
|
|
275
|
+
return blockedDespiteAssertions(noDeliveryCause(receiver, 'run.completed attempt for this run'));
|
|
276
|
+
}
|
|
261
277
|
expect(
|
|
262
278
|
attempts.length,
|
|
263
|
-
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability',
|
|
279
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `the host MUST attempt delivery of run.completed for THIS run to the registered subscriber — ${noDeliveryCause(receiver, 'run.completed attempt for this run')}`),
|
|
264
280
|
).toBeGreaterThan(0);
|
|
265
281
|
const failedThenSucceeded = attempts.filter((a) => a.status === 500).length;
|
|
266
282
|
expect(
|
|
@@ -327,8 +343,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
327
343
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
328
344
|
|
|
329
345
|
const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
|
|
330
|
-
active = receiver
|
|
331
|
-
const sub = await register(receiver
|
|
346
|
+
active = receiver;
|
|
347
|
+
const sub = await register(receiver);
|
|
332
348
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
333
349
|
|
|
334
350
|
// The row id is the FIRST thing this leg names: `register()` asserts under
|
|
@@ -459,8 +475,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
459
475
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no delivery to exhaust`);
|
|
460
476
|
|
|
461
477
|
const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
|
|
462
|
-
active = receiver
|
|
463
|
-
const sub = await register(receiver
|
|
478
|
+
active = receiver;
|
|
479
|
+
const sub = await register(receiver);
|
|
464
480
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
465
481
|
// Same two traps as the leg above (2.34.1): `register()` asserts under the base
|
|
466
482
|
// id, so name this row first; and every `blocked` after it must STAND rather
|
|
@@ -103,14 +103,14 @@
|
|
|
103
103
|
*/
|
|
104
104
|
|
|
105
105
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
106
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
106
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
107
107
|
import { createHmac } from 'node:crypto';
|
|
108
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
109
108
|
import { driver } from '../lib/driver.js';
|
|
110
109
|
import { discoveryFamilies } from '../lib/discovery-capabilities.js';
|
|
111
110
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
112
111
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
113
|
-
import { discoverOwnedTenant
|
|
112
|
+
import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
|
|
113
|
+
import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
114
114
|
import { req } from '../lib/requirement-ids.js';
|
|
115
115
|
|
|
116
116
|
interface DeliveredRequest {
|
|
@@ -118,44 +118,43 @@ interface DeliveredRequest {
|
|
|
118
118
|
readonly body: string;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
async function startReceiver(): Promise<
|
|
121
|
+
async function startReceiver(): Promise<ScopedReceiver & { received: DeliveredRequest[] }> {
|
|
122
122
|
const received: DeliveredRequest[] = [];
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
received.push({ headers, body });
|
|
134
|
-
res.writeHead(204);
|
|
135
|
-
res.end();
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
// Port 0 (ephemeral) by default — nothing outside this process needs to find
|
|
139
|
-
// it. But OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel,
|
|
140
|
-
// and a tunnel has to be pointed at a port the operator knows in ADVANCE. An
|
|
123
|
+
// `startScopedReceiver` (2.37.0) supplies the listening, the pinned-port
|
|
124
|
+
// binding described below, the public front, AND a nonce path that makes this
|
|
125
|
+
// exercise's destination its own. Four webhook files registered the SAME
|
|
126
|
+
// byte-identical front URL before that, and a webhook subscription outlives
|
|
127
|
+
// the file that made it — so a sibling's retries (one of those files answers
|
|
128
|
+
// 500 by design) arrived here indistinguishable from this run's deliveries.
|
|
129
|
+
//
|
|
130
|
+
// The pinned port is still honoured, and still for the reason it was added:
|
|
131
|
+
// OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel, and a
|
|
132
|
+
// tunnel has to be pointed at a port the operator knows in ADVANCE. An
|
|
141
133
|
// ephemeral port makes that variable unusable by anyone not reading the port
|
|
142
134
|
// out of a running process — a gap found by standing up a real TLS front and
|
|
143
|
-
// trying to use the feature, not by reading the code.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
// trying to use the feature, not by reading the code.
|
|
136
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
137
|
+
const headers: Record<string, string> = {};
|
|
138
|
+
for (const [k, v] of Object.entries(hit.headers)) {
|
|
139
|
+
if (typeof v === 'string') headers[k.toLowerCase()] = v;
|
|
140
|
+
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(',');
|
|
141
|
+
}
|
|
142
|
+
received.push({ headers, body: hit.body });
|
|
143
|
+
res.writeHead(204);
|
|
144
|
+
res.end();
|
|
145
|
+
});
|
|
146
|
+
return { ...rx, received };
|
|
152
147
|
}
|
|
153
148
|
|
|
154
|
-
|
|
149
|
+
// Closed through the receiver: `close()` also drops this exercise's nonce from
|
|
150
|
+
// the front-mux registry, so a delivery that arrives after the leg has finished
|
|
151
|
+
// is answered 404 rather than handed to the next exercise's recorder.
|
|
152
|
+
let activeServer: ScopedReceiver | null = null;
|
|
155
153
|
afterEach(async () => {
|
|
156
154
|
if (activeServer) {
|
|
157
|
-
|
|
155
|
+
const rx = activeServer;
|
|
158
156
|
activeServer = null;
|
|
157
|
+
await rx.close();
|
|
159
158
|
}
|
|
160
159
|
});
|
|
161
160
|
|
|
@@ -179,7 +178,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
const receiver = await startReceiver();
|
|
182
|
-
activeServer = receiver
|
|
181
|
+
activeServer = receiver;
|
|
183
182
|
|
|
184
183
|
// Register the webhook.
|
|
185
184
|
// webhooks.md §Register: `events` + `tenantId` are REQUIRED (empty events → 400).
|
|
@@ -190,9 +189,12 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
190
189
|
// tenantId is 403'd by a host that scopes subscriptions by membership
|
|
191
190
|
// (RFC 0093). Single-tenant hosts return undefined ⇒ omit tenantId.
|
|
192
191
|
const ownedTenant = await discoverOwnedTenant(driver);
|
|
193
|
-
|
|
192
|
+
// `receiver.url` is this exercise's own destination — the public front (when
|
|
193
|
+
// wired) plus this receiver's nonce path. It is no longer run through
|
|
194
|
+
// `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
|
|
195
|
+
// the path that makes the subscription ours.
|
|
194
196
|
const reg = await driver.post('/v1/webhooks', {
|
|
195
|
-
url:
|
|
197
|
+
url: receiver.url,
|
|
196
198
|
events: ['run.completed'],
|
|
197
199
|
...(ownedTenant ? { tenantId: ownedTenant } : {}),
|
|
198
200
|
});
|
|
@@ -212,7 +214,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
212
214
|
if (reg.status === 400) {
|
|
213
215
|
const body = reg.json as { error?: string };
|
|
214
216
|
if (body.error === 'webhook_url_rejected') {
|
|
215
|
-
if (!
|
|
217
|
+
if (!receiver.tunnelled) {
|
|
216
218
|
// eslint-disable-next-line no-console
|
|
217
219
|
console.warn(
|
|
218
220
|
'[webhook-signed-delivery] host SSRF guard rejected the loopback receiver; ' +
|
|
@@ -222,7 +224,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
222
224
|
return softSkip('blocked', 'precondition not met — `body.error === \'webhook_url_rejected\'` returned early (seam, prior step, or fixture unavailable)');
|
|
223
225
|
}
|
|
224
226
|
expect.fail(
|
|
225
|
-
`host rejected the operator-supplied public https receiver (${
|
|
227
|
+
`host rejected the operator-supplied public https receiver (${receiver.url}) with ` +
|
|
226
228
|
'webhook_url_rejected. A public https destination is legitimate under ' +
|
|
227
229
|
'webhooks.md §"SSRF protection" and RFC 0093 §"Delivery-time egress validation"; ' +
|
|
228
230
|
'rejecting it is a host defect, not an unmet precondition.',
|
|
@@ -307,15 +309,26 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
307
309
|
// has a subscriber; if nothing arrived HERE, the delivery went somewhere
|
|
308
310
|
// this process cannot see and every assertion below it would be vacuous.
|
|
309
311
|
// It fails — it must never soft-skip.
|
|
312
|
+
//
|
|
313
|
+
// The ONE exception, and it is not a softening (2.37.0): when other traffic
|
|
314
|
+
// DID reach this listener, the path from the host to this process demonstrably
|
|
315
|
+
// works, so a zero is the absence of this exercise's IDENTITY and not of
|
|
316
|
+
// delivery — unmeasured, not unmet. That is recorded `blocked`, which denies
|
|
317
|
+
// certification exactly as a failure does (RFC 0168 §E.1), so the mis-wired
|
|
318
|
+
// front this assertion guards against still cannot read as a pass; a
|
|
319
|
+
// mis-wired front produces no traffic here at all and still fails below.
|
|
320
|
+
if (ourDeliveries.length === 0 && absenceIsUnmeasured(receiver)) {
|
|
321
|
+
return blockedDespiteAssertions(noDeliveryCause(receiver, 'run event for this run'));
|
|
322
|
+
}
|
|
310
323
|
expect(ourDeliveries.length, req('openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature',
|
|
311
324
|
'webhooks.md §"Delivery"',
|
|
312
|
-
|
|
325
|
+
receiver.tunnelled
|
|
313
326
|
? 'host MUST POST at least one event for THIS run to the registered subscriber. ' +
|
|
314
327
|
'Registration was accepted, so zero deliveries observed on the local receiver means ' +
|
|
315
328
|
'either the host did not deliver, or OPENWOP_WEBHOOK_RECEIVER_URL does not actually ' +
|
|
316
|
-
|
|
329
|
+
`front this process. Both are failures; neither is a skip. ${noDeliveryCause(receiver, 'run event for this run')}`
|
|
317
330
|
: 'host MUST POST at least one event for THIS run to a registered subscriber within '
|
|
318
|
-
+ `${DELIVERY_DEADLINE_MS}ms of run.completed`,
|
|
331
|
+
+ `${DELIVERY_DEADLINE_MS}ms of run.completed. ${noDeliveryCause(receiver, 'run event for this run')}`,
|
|
319
332
|
)).toBeGreaterThan(0);
|
|
320
333
|
|
|
321
334
|
// Validate the FIRST delivery's signature contract. Other deliveries
|
|
@@ -418,7 +431,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
418
431
|
}
|
|
419
432
|
|
|
420
433
|
const receiver = await startReceiver();
|
|
421
|
-
activeServer = receiver
|
|
434
|
+
activeServer = receiver;
|
|
422
435
|
|
|
423
436
|
const ownedTenant = await discoverOwnedTenant(driver);
|
|
424
437
|
const reg = await driver.post('/v1/webhooks', {
|
package/src/setup.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { basename, join } from 'node:path';
|
|
|
36
36
|
import { existsSync, readFileSync } from 'node:fs';
|
|
37
37
|
import { PKG_ROOT_PATH } from './lib/paths.js';
|
|
38
38
|
import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
39
|
-
import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState } from './lib/scenario-disposition.js';
|
|
39
|
+
import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState, type TestFailure } from './lib/scenario-disposition.js';
|
|
40
40
|
import { softSkipDisposition, softSkipDispositionSince, softSkipMark } from './lib/soft-skip.js';
|
|
41
41
|
import { ItIdAllocator, takeExplicitRequirementId } from './lib/requirement-ids.js';
|
|
42
42
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-coherence.js';
|
|
@@ -248,6 +248,8 @@ await maybeStartA2AFakePeer();
|
|
|
248
248
|
// Setup-file hooks apply to every file in the worker; state is keyed by file.
|
|
249
249
|
const _fileStates = new Map<string, FileTestState[]>();
|
|
250
250
|
const _fileAssertions = new Map<string, number>();
|
|
251
|
+
/** Per file: the failing cases, so the file row can NAME them (2.37.0). */
|
|
252
|
+
const _fileFailures = new Map<string, TestFailure[]>();
|
|
251
253
|
const _ledgerMarks = new Map<string, number>();
|
|
252
254
|
// Per-`it` recording (v2 charter Phase 1, suite 1.153.0 — the durable G8 fix
|
|
253
255
|
// named in scenario-disposition.ts). Each test gets its own ledger row under
|
|
@@ -415,6 +417,17 @@ afterEach(({ task }) => {
|
|
|
415
417
|
const arr = _fileStates.get(file) ?? [];
|
|
416
418
|
arr.push(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip');
|
|
417
419
|
_fileStates.set(file, arr);
|
|
420
|
+
// Which leg failed, and why (2.37.0). The file row used to say only "one or
|
|
421
|
+
// more assertions in the file failed" — a detail that names no case, quotes no
|
|
422
|
+
// message, and cannot be acted on. A tier-2 operator read exactly that row for
|
|
423
|
+
// `v2-run-bulk-cancel` and had to hand-probe every assertion in the file
|
|
424
|
+
// against production to find out what the host had done.
|
|
425
|
+
if (state === 'fail') {
|
|
426
|
+
const msg = ((task.result?.errors ?? [])[0] as { message?: string } | undefined)?.message;
|
|
427
|
+
const fails = _fileFailures.get(file) ?? [];
|
|
428
|
+
fails.push({ name: task.name, ...(msg === undefined ? {} : { message: msg }) });
|
|
429
|
+
_fileFailures.set(file, fails);
|
|
430
|
+
}
|
|
418
431
|
// RFC 0148 §C assertionCount: how many `expect` calls this test actually made.
|
|
419
432
|
// A leg that early-returns from a gate makes zero, and a file of such legs is
|
|
420
433
|
// an `executed-pass` with assertionCount 0 — visible, and unclassified for a
|
|
@@ -470,13 +483,19 @@ afterEach(({ task }) => {
|
|
|
470
483
|
// `inapplicable` came out `blocked` — which denies certification bundle-wide).
|
|
471
484
|
const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
|
|
472
485
|
const err = (task.result?.errors ?? [])[0] as { message?: string } | undefined;
|
|
473
|
-
const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
|
|
486
|
+
const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2, task.name);
|
|
474
487
|
disposition = rec.disposition;
|
|
475
488
|
detail = rec.detail;
|
|
476
489
|
}
|
|
477
490
|
try {
|
|
478
491
|
const evidence = takeNotedEvidence();
|
|
479
|
-
|
|
492
|
+
// `fold: true` — several `it` legs of one file legitimately share ONE
|
|
493
|
+
// explicit id (27 files do, via a module-level `const ID` handed to
|
|
494
|
+
// `req()`). Without the fold the second leg's verdict hit
|
|
495
|
+
// `recordRequirement`'s one-disposition-per-run throw, was swallowed by the
|
|
496
|
+
// catch below, and never reached the JSONL sink: a file could record
|
|
497
|
+
// `executed-fail` while its own requirement row read `executed-pass`.
|
|
498
|
+
recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file, fold: true, ...(evidence === null ? {} : { evidence }) });
|
|
480
499
|
} catch {
|
|
481
500
|
/* never fail a test for bookkeeping */
|
|
482
501
|
}
|
|
@@ -503,7 +522,7 @@ afterAll(({}, suite) => {
|
|
|
503
522
|
// the marker detail — never to a pass. Floors still REJECT that row, so the
|
|
504
523
|
// honest bundle row and the pressure to say why both survive. The rule is
|
|
505
524
|
// `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
|
|
506
|
-
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file);
|
|
525
|
+
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file, _fileFailures.get(file) ?? []);
|
|
507
526
|
const fileRequirementId = requirementIdForFile(file);
|
|
508
527
|
// A scenario that classified ITSELF wins outright — including its `detail` and
|
|
509
528
|
// its `assertionCount`.
|
|
@@ -530,6 +549,7 @@ afterAll(({}, suite) => {
|
|
|
530
549
|
}
|
|
531
550
|
_fileStates.delete(file);
|
|
532
551
|
_fileAssertions.delete(file);
|
|
552
|
+
_fileFailures.delete(file);
|
|
533
553
|
_ledgerMarks.delete(file);
|
|
534
554
|
_itAllocators.delete(file);
|
|
535
555
|
_itMarks.delete(file);
|