@openwop/openwop-conformance 2.34.2 → 2.35.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * RFC 0194 §A — a run's terminal event is emitted once and ends its forward
3
+ * execution (`spec/v2/core/events.md` §The terminal event). Target major 2.
4
+ *
5
+ * Found by two independently written hosts under duplicate delivery: one wrote
6
+ * `run.completed` then `node.failed`, the other two `run.completed` events
7
+ * (openwop#1445). Nothing in v2 said either was wrong.
8
+ *
9
+ * A DEDICATED file rather than a check bolted onto every scenario that reaches
10
+ * a terminal state: a host that violates this RFC must fail this RFC's rows,
11
+ * not unrelated ones (openwop#1450 showed what misattribution costs).
12
+ *
13
+ * The log is read after it SETTLES — two reads at least 2 s apart returning the
14
+ * same length, bounded at 20 s — because the second delivery's executor can
15
+ * finish after the first terminal event. Growth after a terminal event is the
16
+ * violation's own evidence, not an inconclusive read.
17
+ *
18
+ * @see spec/v2/core/events.md §The terminal event
19
+ * @see RFCS/0194-terminal-event-ends-forward-execution.md
20
+ */
21
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
22
+ import { describe, expect, it } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+ import { v2Discovery } from '../lib/v2.js';
25
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
27
+ import { scaledTimeoutMs } from '../lib/polling.js';
28
+ import { req } from '../lib/requirement-ids.js';
29
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
30
+ import { terminalShapeViolation, TERMINAL_RUN_EVENTS } from '../lib/terminal-shape.js';
31
+
32
+ const ONCE = 'openwop.requirement.0194.terminal-once';
33
+ const DUP = 'openwop.requirement.0194.terminal-once.duplicate-delivery';
34
+ const DOC = 'spec/v2/core/events.md §The terminal event (RFC 0194 §A)';
35
+ const TERMINAL_STATUS = new Set(['completed', 'failed', 'cancelled']);
36
+ const KILL_SEAM = '/host/durability/kill';
37
+
38
+ async function http<T>(fn: () => Promise<T>): Promise<T | null> { try { return await fn(); } catch { return null; } }
39
+
40
+ async function status(runId: string): Promise<string | null> {
41
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
42
+ return r !== null && r.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
43
+ }
44
+ async function waitStatus(runId: string, want: (s: string) => boolean, timeoutMs: number): Promise<string | null> {
45
+ const deadline = Date.now() + scaledTimeoutMs(timeoutMs);
46
+ let s: string | null = null;
47
+ while (Date.now() < deadline) { s = await status(runId); if (s !== null && want(s)) return s; await new Promise((r) => setTimeout(r, 250)); }
48
+ return s;
49
+ }
50
+ async function logTypes(runId: string): Promise<string[] | null> {
51
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
52
+ if (r === null || r.status !== 200) return null;
53
+ const ev = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
54
+ return Array.isArray(ev) ? ev.map((e) => String(e.type)) : null;
55
+ }
56
+ /** Re-read until two reads ≥ 2 s apart agree (bounded); returns the last read. */
57
+ async function settledLog(runId: string): Promise<string[] | null> {
58
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
59
+ let last = await logTypes(runId);
60
+ while (Date.now() < deadline) {
61
+ await new Promise((r) => setTimeout(r, 2_000));
62
+ const next = await logTypes(runId);
63
+ if (next === null) return last;
64
+ if (last !== null && next.length === last.length) return next;
65
+ last = next;
66
+ }
67
+ return last;
68
+ }
69
+
70
+ describe('v2 terminal event (RFC 0194 §A)', () => {
71
+ it('every run observed to a terminal state has exactly one terminal event and no forward execution after it', async () => {
72
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
73
+ req(ONCE, DOC, 'a run\'s log MUST contain exactly one terminal run event and no forward-execution event after it');
74
+ const cases: Array<{ fixture: string; cancel: boolean; want: string }> = [
75
+ { fixture: 'conformance-noop', cancel: false, want: 'completed' },
76
+ { fixture: 'conformance-noop', cancel: false, want: 'completed' },
77
+ { fixture: 'conformance-failure', cancel: false, want: 'failed' },
78
+ { fixture: 'conformance-cancellable', cancel: true, want: 'cancelled' },
79
+ ];
80
+ const observed: Array<{ fixture: string; runId: string }> = [];
81
+ for (const c of cases) {
82
+ if (!isFixtureAdvertised(c.fixture)) continue;
83
+ const create = await http(() => driver.post('/runs', { workflowId: c.fixture }));
84
+ const runId = (create?.json as { runId?: unknown } | null)?.runId;
85
+ if (create === null || create.status !== 201 || typeof runId !== 'string') continue;
86
+ if (c.cancel) await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
87
+ // `cancelling` is not terminal; wait for the terminal status itself.
88
+ const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 30_000);
89
+ if (s !== null && TERMINAL_STATUS.has(s)) observed.push({ fixture: c.fixture, runId });
90
+ }
91
+ if (observed.length === 0) return softSkip('blocked', 'no advertised fixture produced a run that reached a terminal status — there is no log to check');
92
+ for (const o of observed) {
93
+ const types = await settledLog(o.runId);
94
+ if (types === null) return softSkip('blocked', `GET /runs/{runId}/events/poll did not answer for ${o.fixture} — the log is unobservable`);
95
+ expect(terminalShapeViolation(types), req(ONCE, DOC, `${o.fixture} run ${o.runId}: exactly one terminal run event, and no forward-execution event after it — log: ${types.join(' ')}`)).toBeNull();
96
+ }
97
+ }, 180_000);
98
+
99
+ it('work delivered twice still leaves one terminal event and no forward execution after it', async () => {
100
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
101
+ req(DUP, DOC, 'under a duplicate delivery the log MUST still contain exactly one terminal run event and no forward-execution event after it');
102
+ const probe = await http(() => driver.get(KILL_SEAM));
103
+ if (probe === null) return softSkip('blocked', `GET ${KILL_SEAM} unreachable (fetch failed)`);
104
+ if (probe.status === 404 || probe.status === 405) return softSkip('inapplicable', `no RFC 0158 durability test hook at ${KILL_SEAM} (HTTP ${probe.status}) — a black-box suite cannot make a host redeliver accepted work, so this row needs the hook`);
105
+ const rx = await startEffectReceiver();
106
+ try {
107
+ const target = resolveRegistrationUrl(rx.url);
108
+ const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url }));
109
+ const runId = (fired?.json as { runId?: unknown } | null)?.runId;
110
+ if (fired === null || fired.status >= 400 || typeof runId !== 'string') return softSkip('blocked', `the durability hook answered ${fired?.status ?? 'no response'} for mode=duplicate-delivery — no redelivered run to read`);
111
+ const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 60_000);
112
+ if (s === null || !TERMINAL_STATUS.has(s)) return softSkip('blocked', `the redelivered run did not reach a terminal status (last: ${s ?? 'unreadable'})`);
113
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
114
+ while (rx.arrivals === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
115
+ const types = await settledLog(runId);
116
+ if (types === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — the log is unobservable');
117
+ expect(types.some((t) => TERMINAL_RUN_EVENTS.has(t)), req(DUP, DOC, 'the redelivered run\'s log reached a terminal event')).toBe(true);
118
+ expect(terminalShapeViolation(types), req(DUP, DOC, `work delivered twice MUST leave exactly one terminal run event and nothing forward after it — log: ${types.join(' ')}`)).toBeNull();
119
+ } finally {
120
+ await new Promise<void>((ok) => rx.server.close(() => ok()));
121
+ }
122
+ }, 180_000);
123
+ });
124
+
125
+ async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: number }> {
126
+ const state = { arrivals: 0 };
127
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
128
+ request.on('data', () => { /* drain */ });
129
+ request.on('end', () => { state.arrivals++; res.writeHead(204); res.end(); });
130
+ });
131
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
132
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
133
+ const binding = receiverBinding();
134
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
135
+ const addr = server.address();
136
+ const out = { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/effect`, get arrivals() { return state.arrivals; } };
137
+ return out;
138
+ }
@@ -64,6 +64,16 @@ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
64
64
  { url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
65
65
  { url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
66
66
  { url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
67
+ // 2.35.0 — the same MUST, spelled the way a URL parser spells it. An
68
+ // IPv4-mapped IPv6 literal is normalised to HEX (`[::ffff:127.0.0.1]` has
69
+ // hostname `::ffff:7f00:1`), and a guard that matched only the dotted mapped
70
+ // form let loopback, metadata and RFC 1918 through at registration and at
71
+ // delivery. Measured on the steward's own reference host.
72
+ // These are loopback / link-local / RFC 1918 addresses by any reading of
73
+ // webhooks.md §SSRF, so they enforce the existing obligation — same id.
74
+ { url: 'https://[::ffff:7f00:1]/openwop-egress-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
75
+ { url: 'https://[::ffff:a9fe:a9fe]/latest/meta-data/', cls: 'cloud metadata as IPv4-mapped IPv6 (hex)' },
76
+ { url: 'https://[::ffff:a00:1]/openwop-egress-probe', cls: 'RFC 1918 (10/8) as IPv4-mapped IPv6 (hex)' },
67
77
  ];
68
78
 
69
79
  /** The `webhooks.*` obligations the operator declared relaxed, if any. */
package/src/setup.ts CHANGED
@@ -470,7 +470,7 @@ afterEach(({ task }) => {
470
470
  // `inapplicable` came out `blocked` — which denies certification bundle-wide).
471
471
  const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
472
472
  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);
473
+ const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
474
474
  disposition = rec.disposition;
475
475
  detail = rec.detail;
476
476
  }