@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2
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/README.md +2 -2
- package/dist/cli.js +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2744 -4
- package/scenario-majors.json +155 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A.3 / §A.4 — `v1-events-translated` (suite 2.0.0, target major 2;
|
|
3
|
+
* seam-gated on `openwop-conformance-seams-v2`).
|
|
4
|
+
*
|
|
5
|
+
* A v2 host reading a run in era `2` MUST translate every event through
|
|
6
|
+
* `spec/v2/event-codemap.json` at the storage boundary: `type` is mapped,
|
|
7
|
+
* `sequence` is preserved verbatim including `0`, `eventId` / `timestamp` /
|
|
8
|
+
* `causationId` pass through (`spec/v2/core/persistence.md` §The reader rule).
|
|
9
|
+
* The rule binds every reader — poll, SSE and fork are read here so a
|
|
10
|
+
* wrapper-only adapter (one that some call sites bypass, §The seat) is caught.
|
|
11
|
+
*
|
|
12
|
+
* The era-2 log is seeded in v1 vocabulary through the event-log seed seam
|
|
13
|
+
* (lib/era2-seed.ts names the contract) with two renamed codemap rows
|
|
14
|
+
* (`agent.toolCalled` → `agent.tool-called`, `agent.toolReturned` →
|
|
15
|
+
* `agent.tool-returned`). Without the seams profile, or without the seam,
|
|
16
|
+
* every leg records `blocked` naming it.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/persistence.md §The reader rule, §The seat
|
|
19
|
+
* @see spec/v2/core/events.md §Reading an era-2 log
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
import { codemapV1toV2, era2Gate, eventsOf, forkRun, pollEvents, seedEra2Log, streamEvents, v1FixtureLog, type ReadEvent, type SeedEvent } from '../lib/era2-seed.js';
|
|
27
|
+
|
|
28
|
+
const DOC = 'spec/v2/core/persistence.md §The reader rule';
|
|
29
|
+
|
|
30
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
31
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The assertions every reader must satisfy against the seeded log — one requirement id per leg, passed in. */
|
|
35
|
+
function assertTranslated(id: string, reader: string, seeded: readonly SeedEvent[], read: readonly ReadEvent[]): void {
|
|
36
|
+
const map = codemapV1toV2();
|
|
37
|
+
const expectedTypes = seeded.map((e) => map.get(e.type) ?? e.type);
|
|
38
|
+
const renamed = seeded.filter((e) => map.get(e.type) !== undefined && map.get(e.type) !== e.type).map((e) => e.type);
|
|
39
|
+
expect(read.length, req(id, DOC, `${reader} MUST return every seeded row (${seeded.length}) — got ${read.length}`)).toBeGreaterThanOrEqual(seeded.length);
|
|
40
|
+
const bySeq = new Map<number, ReadEvent>();
|
|
41
|
+
for (const e of read) if (typeof e.sequence === 'number') bySeq.set(e.sequence, e);
|
|
42
|
+
expect(bySeq.has(0), req(id, DOC, `${reader}: sequence 0 MUST be present — sequence is preserved verbatim including 0 (RFC 0165 G7)`)).toBe(true);
|
|
43
|
+
for (const s of seeded) {
|
|
44
|
+
const got = bySeq.get(s.sequence);
|
|
45
|
+
expect(got, req(id, DOC, `${reader}: the seeded sequence ${s.sequence} MUST be readable under its own sequence (the sequence space is preserved, never renumbered)`)).toBeDefined();
|
|
46
|
+
expect(got?.type, req(id, DOC, `${reader}: v1 type ${s.type} at sequence ${s.sequence} MUST read back as the codemap's v2 name ${map.get(s.type) ?? s.type}`)).toBe(map.get(s.type) ?? s.type);
|
|
47
|
+
if (s.timestamp !== undefined) {
|
|
48
|
+
expect(got?.timestamp, req(id, DOC, `${reader}: timestamp passes through untouched at sequence ${s.sequence}`)).toBe(s.timestamp);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const leaked = read.map((e) => String(e.type)).filter((t) => renamed.includes(t));
|
|
52
|
+
expect(leaked, req(id, DOC, `${reader}: no v1 name of a renamed row may survive the read (a host MUST NOT carry a private mapping or pass an unmapped name through) — leaked [${leaked.join(', ')}]; expected [${expectedTypes.join(', ')}]`)).toEqual([]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('RFC 0176 §A.3 — v1-events-translated (seam-gated)', () => {
|
|
56
|
+
it('poll reads an era-2 log with v2 type names and sequence 0 present', async () => {
|
|
57
|
+
const doc = await discovery();
|
|
58
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
59
|
+
const gate = era2Gate(doc);
|
|
60
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
61
|
+
const log = v1FixtureLog();
|
|
62
|
+
const seeded = await seedEra2Log(log, 'completed');
|
|
63
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
64
|
+
const res = await pollEvents(seeded.runId);
|
|
65
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll unreachable (fetch failed)');
|
|
66
|
+
expect(res.status, req('openwop.requirement.0176.v1-events-translated.poll', DOC, `poll over an era-2 log the codemap fully names MUST answer 200 (got ${res.status}) — every row is mapped, so nothing refuses the read`)).toBe(200);
|
|
67
|
+
assertTranslated('openwop.requirement.0176.v1-events-translated.poll', 'poll', log, eventsOf(res.json));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('SSE reads the same era-2 log with v2 type names', async () => {
|
|
71
|
+
const doc = await discovery();
|
|
72
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
73
|
+
const gate = era2Gate(doc);
|
|
74
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
75
|
+
const log = v1FixtureLog();
|
|
76
|
+
const seeded = await seedEra2Log(log, 'completed');
|
|
77
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
78
|
+
const stream = await streamEvents(seeded.runId);
|
|
79
|
+
if (stream === null) return softSkip('blocked', 'GET /runs/{runId}/events (SSE) unreachable (fetch failed)');
|
|
80
|
+
expect(stream.status, req('openwop.requirement.0176.v1-events-translated.sse', DOC, `streamRunEvents over an era-2 log MUST answer 200 text/event-stream (got ${stream.status}) — the stream reader is bound by the same rule as poll`)).toBe(200);
|
|
81
|
+
assertTranslated('openwop.requirement.0176.v1-events-translated.sse', 'SSE (streamMode=debug)', log, stream.events);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a fork of the era-2 run reads its inherited prefix with v2 type names', async () => {
|
|
85
|
+
const doc = await discovery();
|
|
86
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
87
|
+
const gate = era2Gate(doc);
|
|
88
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
89
|
+
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — the fork reader has no surface');
|
|
90
|
+
const log = v1FixtureLog();
|
|
91
|
+
const seeded = await seedEra2Log(log, 'completed');
|
|
92
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
93
|
+
const last = Math.max(...log.map((e) => e.sequence));
|
|
94
|
+
const fork = await forkRun(seeded.runId, { mode: 'replay', fromSeq: last });
|
|
95
|
+
if (fork === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
96
|
+
expect(fork.status, req('openwop.requirement.0176.v1-events-translated.fork', 'spec/v2/core/replay.md §Forking a v1 run', `a v2 host MUST fork a run created before the cut — POST /runs/{runId}:fork {mode: replay, fromSeq: ${last}} answered ${fork.status}`)).toBe(201);
|
|
97
|
+
const forkId = (fork.json as { runId?: unknown } | undefined)?.runId;
|
|
98
|
+
if (typeof forkId !== 'string') return softSkip('blocked', 'the fork answered 201 without a runId — the inherited prefix cannot be read');
|
|
99
|
+
const res = await pollEvents(forkId);
|
|
100
|
+
if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{forkId}/events/poll answered ${res?.status ?? 'no response'}`);
|
|
101
|
+
// The fork inherits the translated prefix [0, fromSeq) — the rows below the fork point.
|
|
102
|
+
const prefix = log.filter((e) => e.sequence < last);
|
|
103
|
+
assertTranslated('openwop.requirement.0176.v1-events-translated.fork', 'the fork\'s inherited prefix', prefix, eventsOf(res.json).filter((e) => typeof e.sequence === 'number' && e.sequence < last));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §D.2 — `v1-signed-webhook-accepted` (suite 2.0.0, target major 2;
|
|
3
|
+
* gated on `webhooks`, driven through the seams profile).
|
|
4
|
+
*
|
|
5
|
+
* A v2 receiver MUST accept a delivery carrying only the `X-openwop-*` header
|
|
6
|
+
* family under scheme `v1`, verifying the same bytes (`{timestamp}.{rawBody}`
|
|
7
|
+
* HMAC-SHA256 with the subscription secret); this adds no signature scheme and
|
|
8
|
+
* per-subscription secrets are unchanged across the cut (`spec/v2/core/webhooks.md`
|
|
9
|
+
* §Dual emission through the overlap; persistence.md §Everything else a v1 host
|
|
10
|
+
* persisted; migration row C9.9). The facet's `signatureAlgorithms[]` MUST list
|
|
11
|
+
* `"v1"` (webhooks.md §Surfaces).
|
|
12
|
+
*
|
|
13
|
+
* The receiver under test is the HOST's inbound receiver path — the v2 host as
|
|
14
|
+
* a subscriber (its trigger bridge, its A2A/MCP peer callbacks) — which the
|
|
15
|
+
* canonical API does not expose. The seam this scenario drives:
|
|
16
|
+
*
|
|
17
|
+
* POST /conformance/seams/sample/webhooks/receive
|
|
18
|
+
* { secret, headers: { "X-openwop-Webhook-Id", "X-openwop-Event-Type", "X-openwop-Timestamp",
|
|
19
|
+
* "X-openwop-Signature", "X-openwop-Signature-Algorithm" }, body }
|
|
20
|
+
* → 200 { accepted: boolean, reason? }
|
|
21
|
+
*
|
|
22
|
+
* The host runs the delivery through its production verifier with `secret` as
|
|
23
|
+
* the subscription secret and reports the verdict. Catalogued in
|
|
24
|
+
* `api/seams-v2.yaml` (`receiveWebhookDelivery`); a host answering 404 / 403 / 405 records `blocked`
|
|
25
|
+
* naming it. A control delivery with a wrong signature MUST be refused, so a
|
|
26
|
+
* receiver that accepts everything does not pass.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v2/core/webhooks.md §Dual emission through the overlap
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { createHmac } from 'node:crypto';
|
|
33
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
34
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
35
|
+
import { seamPath, seamsProfileAdvertised } from '../lib/seams.js';
|
|
36
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
37
|
+
import { req } from '../lib/requirement-ids.js';
|
|
38
|
+
|
|
39
|
+
const DOC = 'spec/v2/core/webhooks.md §Dual emission through the overlap';
|
|
40
|
+
const RECEIVE_V1 = '/v1/host/sample/webhooks/receive';
|
|
41
|
+
const RECEIVE = seamPath(RECEIVE_V1);
|
|
42
|
+
|
|
43
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
44
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
47
|
+
try { return await fn(); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An `X-openwop-*`-only scheme-v1 delivery over the given bytes. */
|
|
51
|
+
function v1Delivery(secret: string, body: string, tamper = false): { headers: Record<string, string>; body: string } {
|
|
52
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
53
|
+
const sig = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
|
|
54
|
+
const signature = tamper ? sig.replace(/^./, (c) => (c === '0' ? '1' : '0')) : sig;
|
|
55
|
+
return {
|
|
56
|
+
headers: {
|
|
57
|
+
'X-openwop-Webhook-Id': 'wh-conformance-v1-signed',
|
|
58
|
+
'X-openwop-Event-Type': 'run.completed',
|
|
59
|
+
'X-openwop-Timestamp': timestamp,
|
|
60
|
+
'X-openwop-Signature': `sha256=${signature}`,
|
|
61
|
+
'X-openwop-Signature-Algorithm': 'v1',
|
|
62
|
+
},
|
|
63
|
+
body,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Verdict = { readonly ok: true; readonly accepted: unknown; readonly reason: unknown } | { readonly ok: false; readonly kind: 'blocked'; readonly reason: string };
|
|
68
|
+
|
|
69
|
+
async function deliver(secret: string, delivery: { headers: Record<string, string>; body: string }): Promise<Verdict> {
|
|
70
|
+
const res = await http(() => driver.post(RECEIVE_V1, { secret, headers: delivery.headers, body: delivery.body }));
|
|
71
|
+
if (res === null) return { ok: false, kind: 'blocked', reason: `${RECEIVE} unreachable (fetch failed)` };
|
|
72
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return { ok: false, kind: 'blocked', reason: `no inbound receiver seam — ${RECEIVE} answered ${res.status}; the host's v2 receiver path cannot be driven (RFC 0176 falsifiability §D.2)` };
|
|
73
|
+
if (res.status !== 200 || !res.json || typeof res.json !== 'object') return { ok: false, kind: 'blocked', reason: `${RECEIVE} answered ${res.status} without a 200 { accepted } verdict — the receiver seam contract is not honoured` };
|
|
74
|
+
const j = res.json as { accepted?: unknown; reason?: unknown };
|
|
75
|
+
return { ok: true, accepted: j.accepted, reason: j.reason };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('RFC 0176 §D.2 — v1-signed-webhook-accepted (gated on webhooks + seams)', () => {
|
|
79
|
+
it('the webhooks facet lists scheme "v1" — the scheme a v1-signed delivery is verified under', async () => {
|
|
80
|
+
const doc = await discovery();
|
|
81
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
82
|
+
const webhooks = await gateFamily('webhooks');
|
|
83
|
+
if (!webhooks) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
84
|
+
const algorithms = webhooks['signatureAlgorithms'];
|
|
85
|
+
expect(Array.isArray(algorithms), req('openwop.requirement.0176.v1-signed-webhook-accepted.facet', 'spec/v2/core/webhooks.md §Surfaces', 'the webhooks facet is { signatureAlgorithms[] }')).toBe(true);
|
|
86
|
+
expect(algorithms, req('openwop.requirement.0176.v1-signed-webhook-accepted.facet', 'spec/v2/core/webhooks.md §Surfaces', 'signatureAlgorithms MUST list "v1" — the cut adds no signature scheme (RFC 0176 §D.2)')).toContain('v1');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('the host\'s v2 receiver path accepts an X-openwop-*-only scheme-v1 delivery and refuses a tampered one', async () => {
|
|
90
|
+
const doc = await discovery();
|
|
91
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
92
|
+
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
93
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('blocked', `seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — the host's inbound receiver is reachable only through ${RECEIVE}`);
|
|
94
|
+
const secret = `conformance-secret-${Date.now().toString(36)}`;
|
|
95
|
+
const body = JSON.stringify({ runId: 'run-conformance-v1-signed', workspaceId: 'ws-conformance', event: { type: 'run.completed', sequence: 3, payload: { durationMs: 1 } } });
|
|
96
|
+
const good = await deliver(secret, v1Delivery(secret, body));
|
|
97
|
+
if (!good.ok) return softSkip(good.kind, good.reason);
|
|
98
|
+
expect(
|
|
99
|
+
good.accepted,
|
|
100
|
+
req('openwop.requirement.0176.v1-signed-webhook-accepted', DOC, `a v2 receiver MUST accept a delivery carrying only the X-openwop-* family under scheme v1, verifying the same bytes — refused${good.reason !== undefined ? ` (${String(good.reason)})` : ''}`),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
const bad = await deliver(secret, v1Delivery(secret, body, true));
|
|
103
|
+
if (!bad.ok) return softSkip(bad.kind, `${bad.reason} — the acceptance was witnessed, the tamper control was not`);
|
|
104
|
+
expect(
|
|
105
|
+
bad.accepted,
|
|
106
|
+
req('openwop.requirement.0176.v1-signed-webhook-accepted', 'spec/v2/core/webhooks.md §Verification', 'a receiver MUST verify before acting: the same delivery with a wrong X-openwop-Signature MUST be refused (a receiver that accepts everything is not verifying the v1 bytes)'),
|
|
107
|
+
).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §B — `webhook-durable-delivery` (suite 2.0.0, target major 2; gated on `webhooks`).
|
|
3
|
+
*
|
|
4
|
+
* In v2 durable delivery binds with the `webhooks` surface: a host MUST retry a
|
|
5
|
+
* failed attempt per its advertised `retryPolicy`, MUST route an exhausted
|
|
6
|
+
* delivery to the dead-letter sink rather than drop it, and MUST deliver at
|
|
7
|
+
* least once; best-effort is not a conforming mode and a `3xx` is a failure
|
|
8
|
+
* (`spec/v2/core/webhooks.md` §Durability; security-defaults.md §Webhook
|
|
9
|
+
* durability; RFC 0173 §B row C6.3).
|
|
10
|
+
*
|
|
11
|
+
* How the receiver is driven: the suite boots its own HTTP receiver (the same
|
|
12
|
+
* shape `webhook-signed-delivery.test.ts` uses) in a FAILING mode — it answers
|
|
13
|
+
* `500` to the first N attempts for a delivery key and `204` afterwards — so
|
|
14
|
+
* the retry is observable as more than one attempt for one
|
|
15
|
+
* `(webhookId, runId, sequence)` key, and the eventual `204` is the at-least-once
|
|
16
|
+
* delivery. A second receiver never succeeds, so the retries exhaust; the
|
|
17
|
+
* dead-letter leg then needs a read surface for the sink, which
|
|
18
|
+
* `api/v2/openapi.yaml` does not carry — that leg records `blocked` naming it.
|
|
19
|
+
*
|
|
20
|
+
* Registration goes through the canonical `POST /webhooks` with the v2 body
|
|
21
|
+
* (`{ url, events[] }` — no `tenantId`, the v2 body is closed). The SSRF posture
|
|
22
|
+
* is the one `webhook-signed-delivery.test.ts` documents: a loopback receiver is
|
|
23
|
+
* rejected by a conforming host unless `OPENWOP_WEBHOOK_RECEIVER_URL` fronts it
|
|
24
|
+
* with a public https tunnel; a rejection of the loopback URL is `blocked`.
|
|
25
|
+
*
|
|
26
|
+
* @see spec/v2/core/webhooks.md §Durability
|
|
27
|
+
* @see spec/v2/core/security-defaults.md §Webhook durability
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { afterEach, describe, it, expect } from 'vitest';
|
|
31
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
34
|
+
import { resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
35
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
36
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
37
|
+
import { req } from '../lib/requirement-ids.js';
|
|
38
|
+
|
|
39
|
+
const FIXTURE = 'conformance-noop';
|
|
40
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
41
|
+
|
|
42
|
+
interface Attempt { readonly key: string; readonly runId: string | null; readonly status: number; readonly at: number }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A receiver that fails the first `failFirst` attempts for each delivery key
|
|
46
|
+
* (`Infinity` ⇒ always fails). The key is `(webhookId, runId, sequence)` —
|
|
47
|
+
* the dedup triple webhooks.md §Verification names.
|
|
48
|
+
*/
|
|
49
|
+
async function startReceiver(failFirst: number): Promise<{ server: Server; url: string; attempts: Attempt[] }> {
|
|
50
|
+
const attempts: Attempt[] = [];
|
|
51
|
+
const seen = new Map<string, number>();
|
|
52
|
+
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
53
|
+
const chunks: Buffer[] = [];
|
|
54
|
+
request.on('data', (c: Buffer) => chunks.push(c));
|
|
55
|
+
request.on('end', () => {
|
|
56
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
57
|
+
let runId: string | null = null;
|
|
58
|
+
let sequence: unknown = null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(body) as { runId?: unknown; event?: { sequence?: unknown } };
|
|
61
|
+
runId = typeof parsed.runId === 'string' ? parsed.runId : null;
|
|
62
|
+
sequence = parsed.event?.sequence ?? null;
|
|
63
|
+
} catch { /* not JSON — still an attempt */ }
|
|
64
|
+
const h = request.headers;
|
|
65
|
+
const webhookId = String(h['openwop-webhook-id'] ?? h['x-openwop-webhook-id'] ?? '');
|
|
66
|
+
const key = `${webhookId}|${runId ?? ''}|${String(sequence)}`;
|
|
67
|
+
const n = (seen.get(key) ?? 0) + 1;
|
|
68
|
+
seen.set(key, n);
|
|
69
|
+
const status = n <= failFirst ? 500 : 204;
|
|
70
|
+
attempts.push({ key, runId, status, at: Date.now() });
|
|
71
|
+
res.writeHead(status);
|
|
72
|
+
res.end();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
76
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
77
|
+
await new Promise<void>((resolve) => server.listen(bindPort, '127.0.0.1', () => resolve()));
|
|
78
|
+
const addr = server.address();
|
|
79
|
+
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
80
|
+
return { server, url: `http://127.0.0.1:${addr.port}/`, attempts };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let active: Server | null = null;
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
if (active) {
|
|
86
|
+
const s = active;
|
|
87
|
+
active = null;
|
|
88
|
+
await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
93
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fixtureAdvertised(doc: Record<string, unknown>, id: string): boolean {
|
|
97
|
+
return Array.isArray(doc['fixtures']) && (doc['fixtures'] as unknown[]).includes(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function waitTerminal(runId: string, timeoutMs: number): Promise<string | null> {
|
|
101
|
+
const deadline = Date.now() + timeoutMs;
|
|
102
|
+
for (;;) {
|
|
103
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
104
|
+
const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
105
|
+
if (status !== null && TERMINAL.has(status)) return status;
|
|
106
|
+
if (Date.now() > deadline) return status;
|
|
107
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function waitFor(pred: () => boolean, timeoutMs: number): Promise<boolean> {
|
|
112
|
+
const deadline = Date.now() + timeoutMs;
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
if (pred()) return true;
|
|
115
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
116
|
+
}
|
|
117
|
+
return pred();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The retry policy the host advertises, where it does (`triggerBridge.retryPolicy` is the only v2 carrier; absent ⇒ null). */
|
|
121
|
+
function advertisedRetryPolicy(doc: Record<string, unknown>): { maxAttempts?: number; backoff?: string } | null {
|
|
122
|
+
const tb = doc['triggerBridge'];
|
|
123
|
+
const rp = tb && typeof tb === 'object' ? (tb as { retryPolicy?: unknown }).retryPolicy : undefined;
|
|
124
|
+
return rp && typeof rp === 'object' ? (rp as { maxAttempts?: number; backoff?: string }) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Register the suite receiver; null (with a note) when the host's SSRF guard refuses a loopback URL. */
|
|
128
|
+
async function register(url: string): Promise<{ webhookId: string } | null> {
|
|
129
|
+
const registration = resolveRegistrationUrl(url);
|
|
130
|
+
const reg = await driver.post('/webhooks', { url: registration.url, events: ['run.completed'] });
|
|
131
|
+
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
132
|
+
if (!registration.tunnelled) {
|
|
133
|
+
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');
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
expect.fail(`host rejected the operator-supplied public https receiver (${registration.url}) with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress`);
|
|
137
|
+
}
|
|
138
|
+
expect(reg.status, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
|
|
139
|
+
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
140
|
+
expect(typeof webhookId, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'the 201 body MUST carry `webhookId`')).toBe('string');
|
|
141
|
+
return { webhookId: webhookId as string };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () => {
|
|
145
|
+
it('a failed attempt is retried and the event is delivered at least once', async () => {
|
|
146
|
+
const doc = await discovery();
|
|
147
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
148
|
+
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
149
|
+
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
150
|
+
|
|
151
|
+
const receiver = await startReceiver(2); // 500, 500, then 204
|
|
152
|
+
active = receiver.server;
|
|
153
|
+
const sub = await register(receiver.url);
|
|
154
|
+
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
155
|
+
|
|
156
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
157
|
+
expect(create.status, req('openwop.requirement.0173.webhook-durable-delivery', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
158
|
+
const runId = (create.json as { runId: string }).runId;
|
|
159
|
+
await waitTerminal(runId, 10_000);
|
|
160
|
+
|
|
161
|
+
const ours = () => receiver.attempts.filter((a) => a.runId === runId);
|
|
162
|
+
const retried = await waitFor(() => ours().some((a) => a.status === 204), 20_000);
|
|
163
|
+
const attempts = ours();
|
|
164
|
+
expect(
|
|
165
|
+
attempts.length,
|
|
166
|
+
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'),
|
|
167
|
+
).toBeGreaterThan(0);
|
|
168
|
+
const failedThenSucceeded = attempts.filter((a) => a.status === 500).length;
|
|
169
|
+
expect(
|
|
170
|
+
attempts.length,
|
|
171
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `a 500 from the subscriber MUST be retried — ${failedThenSucceeded} failed attempt(s) were answered and the host made ${attempts.length} attempt(s) in total; one attempt is best-effort delivery, which is not a conforming mode (RFC 0173 §B)`),
|
|
172
|
+
).toBeGreaterThan(1);
|
|
173
|
+
expect(
|
|
174
|
+
retried,
|
|
175
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', 'at-least-once: after the failing attempts the retry MUST land (the receiver answered 204 to the third attempt for the key)'),
|
|
176
|
+
).toBe(true);
|
|
177
|
+
// Backoff: the retry MUST NOT be a tight loop — consecutive attempts for one
|
|
178
|
+
// key are spaced. Only asserted when the host advertises a non-`none` backoff.
|
|
179
|
+
const policy = advertisedRetryPolicy(doc);
|
|
180
|
+
const byKey = new Map<string, number[]>();
|
|
181
|
+
for (const a of attempts) byKey.set(a.key, [...(byKey.get(a.key) ?? []), a.at]);
|
|
182
|
+
if (policy?.backoff !== undefined && policy.backoff !== 'none') {
|
|
183
|
+
for (const [key, times] of byKey) {
|
|
184
|
+
for (let i = 1; i < times.length; i++) {
|
|
185
|
+
expect(
|
|
186
|
+
times[i]! - times[i - 1]!,
|
|
187
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST be spaced by the advertised ${policy.backoff} backoff (attempt ${i + 1} followed attempt ${i} after ${times[i]! - times[i - 1]!}ms)`),
|
|
188
|
+
).toBeGreaterThan(0);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
softSkip('inapplicable', 'no advertised retryPolicy.backoff other than none — the spacing leg is not asserted');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const del = await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
196
|
+
expect(del.status, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'DELETE /webhooks/{webhookId} MUST answer 204')).toBe(204);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('an exhausted delivery is dead-lettered, never dropped', async () => {
|
|
200
|
+
const doc = await discovery();
|
|
201
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
202
|
+
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
203
|
+
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
204
|
+
|
|
205
|
+
const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
|
|
206
|
+
active = receiver.server;
|
|
207
|
+
const sub = await register(receiver.url);
|
|
208
|
+
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
209
|
+
|
|
210
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
211
|
+
expect(create.status, req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
212
|
+
const runId = (create.json as { runId: string }).runId;
|
|
213
|
+
await waitTerminal(runId, 10_000);
|
|
214
|
+
const ours = () => receiver.attempts.filter((a) => a.runId === runId);
|
|
215
|
+
await waitFor(() => ours().length > 1, 20_000);
|
|
216
|
+
const attempts = ours();
|
|
217
|
+
expect(
|
|
218
|
+
attempts.length,
|
|
219
|
+
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', 'a delivery that keeps failing MUST be retried before it can be exhausted (one attempt is a drop)'),
|
|
220
|
+
).toBeGreaterThan(1);
|
|
221
|
+
const policy = advertisedRetryPolicy(doc);
|
|
222
|
+
if (policy?.maxAttempts !== undefined) {
|
|
223
|
+
// Give the policy time to exhaust, then the host MUST stop.
|
|
224
|
+
await waitFor(() => ours().length >= policy.maxAttempts!, 20_000);
|
|
225
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
226
|
+
expect(
|
|
227
|
+
ours().length,
|
|
228
|
+
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', `retries MUST stop at the advertised retryPolicy.maxAttempts (${policy.maxAttempts}) — exhaustion routes to the dead-letter sink, not to an unbounded loop`),
|
|
229
|
+
).toBeLessThanOrEqual(policy.maxAttempts);
|
|
230
|
+
}
|
|
231
|
+
await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
232
|
+
// The sink itself: webhooks.md §Durability says "inspectable for retentionDays",
|
|
233
|
+
// but api/v2/openapi.yaml carries no dead-letter read for webhook deliveries
|
|
234
|
+
// (no GET /webhooks/{webhookId}/dead-letters). Without a normative read the
|
|
235
|
+
// routing to the sink is not observable from the suite.
|
|
236
|
+
return softSkip('blocked', 'no normative dead-letter read surface for webhook deliveries in api/v2/openapi.yaml (a GET /webhooks/{webhookId}/dead-letters projection is needed) — exhaustion was observed, routing to the sink was not');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §C — `well-known-one-resource` (suite 2.0.0, target major 2; gated
|
|
3
|
+
* on a host advertising two majors).
|
|
4
|
+
*
|
|
5
|
+
* `/.well-known/openwop` is ONE resource whose representation the RFC 0172
|
|
6
|
+
* §A.3 header selects: no `OpenWOP-Version` ⇒ the v1 document (with
|
|
7
|
+
* `protocolVersions[]` additive, RFC 0165 §A) through the overlap;
|
|
8
|
+
* `OpenWOP-Version: 2.0` ⇒ the closed v2 root (RFC 0169 §A.4). A single fetch
|
|
9
|
+
* answers the major the client speaks and names the other, so
|
|
10
|
+
* `protocolVersions[]` is equal in both representations (§C.1). The wrapper
|
|
11
|
+
* (`capabilities-wrapper`), the dotted mirror (`host-dotted-mirror`),
|
|
12
|
+
* `Capabilities-Etag` (`capabilities-etag-header`) and the root `profiles[]`
|
|
13
|
+
* are absent from the v2 representation at the cut (§C.2; RFC 0169 §C.1).
|
|
14
|
+
*
|
|
15
|
+
* The two GETs are issued raw (lib/driver.ts always names a version under
|
|
16
|
+
* target major 2 and the header-less request is the point of leg 1);
|
|
17
|
+
* `getCapabilities` carries `security: []`, so neither is authenticated.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v2/core/versioning.md (RFC 0172 §A.3 — the header selects)
|
|
20
|
+
* @see spec/v2/core/capabilities.md §7 (no `profiles[]` at the root)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { loadEnv } from '../lib/env.js';
|
|
25
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
26
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
27
|
+
import { req } from '../lib/requirement-ids.js';
|
|
28
|
+
|
|
29
|
+
const PATH = '/.well-known/openwop';
|
|
30
|
+
const DOC = 'RFC 0176 §C.1 (one well-known resource)';
|
|
31
|
+
|
|
32
|
+
interface Representation { readonly status: number; readonly headers: Headers; readonly body: Record<string, unknown> | null }
|
|
33
|
+
|
|
34
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
35
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function representation(version: string | null): Promise<Representation | null> {
|
|
39
|
+
const env = loadEnv();
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${env.baseUrl}${PATH}`, { headers: { Accept: 'application/json', ...(version === null ? {} : { 'OpenWOP-Version': version }) } });
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
let body: Record<string, unknown> | null = null;
|
|
44
|
+
try { const j = JSON.parse(text) as unknown; body = j && typeof j === 'object' && !Array.isArray(j) ? (j as Record<string, unknown>) : null; } catch { body = null; }
|
|
45
|
+
return { status: res.status, headers: res.headers, body };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function versionsOf(doc: Record<string, unknown> | null): string[] | null {
|
|
52
|
+
const v = doc?.['protocolVersions'];
|
|
53
|
+
return Array.isArray(v) ? v.map(String) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Both majors advertised by the v2 representation, else the reason the scenario does not apply. */
|
|
57
|
+
function twoMajors(doc: Record<string, unknown>): string | null {
|
|
58
|
+
const versions = versionsOf(doc) ?? [];
|
|
59
|
+
const majors = new Set(versions.map((v) => v.split('.')[0]));
|
|
60
|
+
if (majors.has('1') && majors.has('2')) return null;
|
|
61
|
+
return `the host advertises protocolVersions [${versions.join(', ')}] — one major only; the one-resource rule is witnessable on a dual-advertising host (RFC 0176 falsifiability §C.1 "gated on two majors")`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('RFC 0176 §C — well-known-one-resource (gated on two majors)', () => {
|
|
65
|
+
it('a header-less GET and an OpenWOP-Version: 2.0 GET are two representations of one path with equal protocolVersions[]', async () => {
|
|
66
|
+
const doc = await discovery();
|
|
67
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
68
|
+
const gate = twoMajors(doc);
|
|
69
|
+
if (gate !== null) return softSkip('inapplicable', gate);
|
|
70
|
+
const v1 = await representation(null);
|
|
71
|
+
const v2 = await representation('2.0');
|
|
72
|
+
if (v1 === null || v2 === null) return softSkip('blocked', `${PATH} unreachable (fetch failed) for one of the two representations`);
|
|
73
|
+
expect(
|
|
74
|
+
v1.status,
|
|
75
|
+
req('openwop.requirement.0176.well-known-one-resource', DOC, `the header-less GET selects the v1 representation through the overlap and MUST answer 200 (got ${v1.status}) — no header ⇒ v1 (RFC 0172 §A.3)`),
|
|
76
|
+
).toBe(200);
|
|
77
|
+
expect(
|
|
78
|
+
v2.status,
|
|
79
|
+
req('openwop.requirement.0176.well-known-one-resource', DOC, `GET ${PATH} with OpenWOP-Version: 2.0 MUST answer 200 with the closed v2 root (got ${v2.status})`),
|
|
80
|
+
).toBe(200);
|
|
81
|
+
expect(v1.body !== null && v2.body !== null, req('openwop.requirement.0176.well-known-one-resource', DOC, 'both representations MUST be JSON objects')).toBe(true);
|
|
82
|
+
const v1Versions = versionsOf(v1.body);
|
|
83
|
+
const v2Versions = versionsOf(v2.body);
|
|
84
|
+
expect(
|
|
85
|
+
v1Versions,
|
|
86
|
+
req('openwop.requirement.0176.well-known-one-resource', DOC, 'the v1 representation MUST carry protocolVersions[] (RFC 0165 §A, additive) — a single fetch names the other major'),
|
|
87
|
+
).not.toBeNull();
|
|
88
|
+
expect(
|
|
89
|
+
v2Versions,
|
|
90
|
+
req('openwop.requirement.0176.well-known-one-resource', DOC, 'the v2 representation MUST carry protocolVersions[]'),
|
|
91
|
+
).not.toBeNull();
|
|
92
|
+
expect(
|
|
93
|
+
[...(v1Versions ?? [])].sort(),
|
|
94
|
+
req('openwop.requirement.0176.well-known-one-resource', DOC, `protocolVersions[] MUST be equal in both representations — one resource, two renderings (v1: [${(v1Versions ?? []).join(', ')}], v2: [${(v2Versions ?? []).join(', ')}])`),
|
|
95
|
+
).toEqual([...(v2Versions ?? [])].sort());
|
|
96
|
+
// The two are representations, not one document: the v1 rendering is the
|
|
97
|
+
// wrapped document and the v2 rendering is the closed root, so they differ.
|
|
98
|
+
expect(
|
|
99
|
+
JSON.stringify(v1.body) === JSON.stringify(v2.body),
|
|
100
|
+
req('openwop.requirement.0176.well-known-one-resource', DOC, 'the header MUST select a representation — a host that serves one document for both majors is the charter\'s "per-major sub-objects" the RFC does not adopt (adversarial review 3)'),
|
|
101
|
+
).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('the v2 representation carries no `capabilities` wrapper, no dotted `host.*` key, no `profiles[]`, no Capabilities-Etag', async () => {
|
|
105
|
+
const doc = await discovery();
|
|
106
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
107
|
+
const gate = twoMajors(doc);
|
|
108
|
+
if (gate !== null) return softSkip('inapplicable', gate);
|
|
109
|
+
const v2 = await representation('2.0');
|
|
110
|
+
if (v2 === null || v2.status !== 200 || v2.body === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Version: 2.0 answered ${v2?.status ?? 'no response'} without a JSON object`);
|
|
111
|
+
const keys = Object.keys(v2.body);
|
|
112
|
+
expect(
|
|
113
|
+
keys.includes('capabilities'),
|
|
114
|
+
req('openwop.requirement.0176.well-known-one-resource.v2-representation', 'RFC 0176 §C.2', 'the `capabilities` wrapper (deprecation capabilities-wrapper, removalTrigger v2.0-cut) MUST be absent from the v2 representation'),
|
|
115
|
+
).toBe(false);
|
|
116
|
+
expect(
|
|
117
|
+
keys.filter((k) => k.startsWith('host.')),
|
|
118
|
+
req('openwop.requirement.0176.well-known-one-resource.v2-representation', 'RFC 0176 §C.2', 'the dotted `host.*` mirror (deprecation host-dotted-mirror) MUST be absent from the v2 representation'),
|
|
119
|
+
).toEqual([]);
|
|
120
|
+
expect(
|
|
121
|
+
keys.includes('profiles'),
|
|
122
|
+
req('openwop.requirement.0176.well-known-one-resource.v2-representation', 'spec/v2/core/capabilities.md §7', 'no `profiles[]` exists at the v2 root (RFC 0169 §C.1, row C2.10)'),
|
|
123
|
+
).toBe(false);
|
|
124
|
+
expect(
|
|
125
|
+
v2.headers.get('capabilities-etag'),
|
|
126
|
+
req('openwop.requirement.0176.well-known-one-resource.v2-representation', 'RFC 0176 §C.2', 'Capabilities-Etag (deprecation capabilities-etag-header) MUST be absent from the v2 representation — the standard ETag is the probe handle'),
|
|
127
|
+
).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|