@openwop/openwop-conformance 2.45.10 → 2.45.12
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 +31 -0
- package/README.md +2 -2
- package/dist/lib/profiles.js +3 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/connection-packs/connection-pack-acme-widgets-rival.json +55 -0
- package/fixtures/connection-packs/connection-pack-acme-widgets.json +29 -5
- package/fixtures.md +1 -0
- package/package.json +2 -2
- package/requirements.json +235 -24
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +19 -17
- package/src/lib/major-profile.ts +17 -0
- package/src/lib/profiles.ts +3 -1
- package/src/lib/provider-registry-double.ts +70 -0
- package/src/lib/provider-registry-witness.ts +107 -0
- package/src/lib/trigger-delivery-witness.ts +340 -0
- package/src/lib/trigger-double.ts +133 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +61 -2
- package/src/scenarios/v2-advertised-path-space-served.test.ts +12 -2
- package/src/scenarios/v2-provider-conflict.test.ts +77 -17
- package/src/scenarios/v2-trigger-bridge-delivery.test.ts +136 -0
- package/src/scenarios/v2-trigger-dead-letter-read.test.ts +78 -0
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
* precedence is gone. A connector MAY name a provider by the qualified form
|
|
10
10
|
* `<packName>#<id>`, which resolves only to the named pack's definition.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* TWO WITNESS PATHS (RFC 0233), and a host is measured on every path it offers.
|
|
13
|
+
* The NORMATIVE-SURFACE path reads `GET /connection-providers` and
|
|
14
|
+
* `GET /connection-providers/{providerId}` when the host advertises
|
|
15
|
+
* `connections.providerRead`; the conflict was caused operator-side by
|
|
16
|
+
* installing the §D fixture pair, and the registry describes the outcome
|
|
17
|
+
* (`lib/provider-registry-witness.ts`). Without the fixture installed, its
|
|
18
|
+
* legs are `inapplicable`; the uniqueness leg runs on any host with the facet.
|
|
19
|
+
* With neither `providerRead` nor the seams the legs are `inapplicable`
|
|
20
|
+
* (RFC 0233 §Decisions 2) — a host advertising `packsSupported` SHOULD
|
|
21
|
+
* advertise `providerRead`. Until 2.45.12 that case recorded `blocked`, a
|
|
22
|
+
* seam-only MUST that `conformance.md` §Witness class forbids.
|
|
23
|
+
*
|
|
24
|
+
* The SEAM path is driven through the RFC 0095 install/resolve seams
|
|
13
25
|
* (`POST /v1/host/sample/connection-packs/{install,resolve}`,
|
|
14
26
|
* `spec/v1/host-sample-test-seams.md` §10; the driver rewrites the v1 seam path
|
|
15
27
|
* to `/conformance/seams/sample/…` under target major 2), gated on
|
|
@@ -37,6 +49,7 @@
|
|
|
37
49
|
* rather than assumed.
|
|
38
50
|
*
|
|
39
51
|
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §D.1
|
|
52
|
+
* @see RFCS/0233-connection-provider-registry-read.md
|
|
40
53
|
* @see spec/v2/core/connection-packs.md
|
|
41
54
|
* @see spec/v1/host-sample-test-seams.md §10
|
|
42
55
|
*/
|
|
@@ -50,7 +63,9 @@ import { behaviorGate } from '../lib/behavior-gate.js';
|
|
|
50
63
|
import { req } from '../lib/requirement-ids.js';
|
|
51
64
|
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
52
65
|
import { targetMajor } from '../lib/seams.js';
|
|
53
|
-
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
66
|
+
import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
|
|
67
|
+
import { noteObservation } from '../lib/row-observation.js';
|
|
68
|
+
import { failClosedLeg, providerReadAdvertised, qualifiedLeg, uniqueLeg, type RegistryOutcome } from '../lib/provider-registry-witness.js';
|
|
54
69
|
|
|
55
70
|
const SECTION = 'connection-packs.md §"Provider identity" (RFC 0177 §D.1)';
|
|
56
71
|
const FIXTURE = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-acme-widgets.json');
|
|
@@ -63,7 +78,8 @@ interface ResolveResult { resolved?: boolean; source?: 'pack' | 'builtin'; versi
|
|
|
63
78
|
|
|
64
79
|
function fixture(): Manifest {
|
|
65
80
|
const m = JSON.parse(readFileSync(FIXTURE, 'utf8')) as Manifest;
|
|
66
|
-
//
|
|
81
|
+
// Both fixture files carry `>=2.0.0 <3.0.0` on disk since 2.45.12 (an operator installs them
|
|
82
|
+
// as-is for RFC 0233 §D). The rewrite is kept so an older checked-out fixture still installs.
|
|
67
83
|
return { ...m, engines: { openwop: '>=2.0.0 <3.0.0' } };
|
|
68
84
|
}
|
|
69
85
|
function conflicting(base: Manifest): Manifest {
|
|
@@ -73,7 +89,7 @@ function codes(r: InstallResult | undefined): string[] {
|
|
|
73
89
|
return (r?.errors ?? []).map((e) => e.code ?? '').filter((c) => c.length > 0);
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
92
|
+
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | { connections: Record<string, unknown> | null }> {
|
|
77
93
|
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
78
94
|
let doc: Record<string, unknown> | null;
|
|
79
95
|
try {
|
|
@@ -85,7 +101,22 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
85
101
|
const connections = await familyAdvertised('connections');
|
|
86
102
|
if (!behaviorGate('connections.packsSupported', connections?.['packsSupported'] === true)) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise connections.packsSupported (RFC 0095 §C)' };
|
|
87
103
|
if (!existsSync(FIXTURE)) return { kind: 'blocked', reason: 'fixture connection-packs/connection-pack-acme-widgets.json is absent from this layout' };
|
|
88
|
-
return
|
|
104
|
+
return { connections };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const NO_PATH = 'connections.packsSupported is advertised, but the host serves neither connections.providerRead (RFC 0233) nor the RFC 0095 seams, so no party can observe a provider conflict here (RFC 0233 §Decisions 2: a host advertising packsSupported SHOULD advertise providerRead)';
|
|
108
|
+
|
|
109
|
+
type SeamLeg = 'absent' | 'ran' | { kind: SoftSkipKind; reason: string };
|
|
110
|
+
|
|
111
|
+
/** Map a normative-surface outcome to assertions. `observed` when it asserted; else the skip to fall back on. */
|
|
112
|
+
function recordNormative(label: (doc: string, message: string) => string, out: RegistryOutcome): { kind: SoftSkipKind; reason: string } | 'observed' {
|
|
113
|
+
if (out.kind === 'skip') return { kind: out.disposition, reason: out.reason };
|
|
114
|
+
for (const x of out.findings) expect(x.ok, label(x.doc, x.message)).toBe(true);
|
|
115
|
+
return 'observed';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function noteWitnessPaths(normative: boolean, seam: boolean): void {
|
|
119
|
+
noteObservation([normative ? 'normative-surface path (RFC 0233 provider registry read)' : '', seam ? 'seam path (RFC 0095)' : ''].filter((x) => x !== '').join(' + '));
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
/** Install the fixture; null when the seam is unwired. */
|
|
@@ -97,10 +128,39 @@ async function installFixture(): Promise<{ res: InstallResult | undefined; statu
|
|
|
97
128
|
|
|
98
129
|
describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
|
|
99
130
|
it('two definitions of one bare provider id fail closed: the later registration is refused with connection_provider_conflict', async () => {
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
131
|
+
const pre = await preflight();
|
|
132
|
+
if ('kind' in pre) return softSkip(pre.kind, pre.reason);
|
|
133
|
+
const normative = providerReadAdvertised(pre.connections) ? recordNormative((doc, m) => req('openwop.requirement.0177.provider-conflict.fail-closed', doc, m), await failClosedLeg()) : null;
|
|
134
|
+
const seam = await seamFailClosed();
|
|
135
|
+
if (typeof seam === 'object') return softSkip(seam.kind, seam.reason);
|
|
136
|
+
if (normative !== 'observed' && seam === 'absent') return softSkip(normative?.kind ?? 'inapplicable', normative?.reason ?? NO_PATH);
|
|
137
|
+
noteWitnessPaths(normative === 'observed', seam === 'ran');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('the qualified form <packName>#acme-widgets resolves to the named pack\'s definition', async () => {
|
|
141
|
+
const pre = await preflight();
|
|
142
|
+
if ('kind' in pre) return softSkip(pre.kind, pre.reason);
|
|
143
|
+
const normative = providerReadAdvertised(pre.connections) ? recordNormative((doc, m) => req('openwop.requirement.0177.provider-conflict.qualified-form', doc, m), await qualifiedLeg()) : null;
|
|
144
|
+
const seam = await seamQualified();
|
|
145
|
+
if (typeof seam === 'object') return softSkip(seam.kind, seam.reason);
|
|
146
|
+
if (normative !== 'observed' && seam === 'absent') return softSkip(normative?.kind ?? 'inapplicable', normative?.reason ?? NO_PATH);
|
|
147
|
+
noteWitnessPaths(normative === 'observed', seam === 'ran');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('the provider registry lists each bare id once and carries no endpoint or credential (RFC 0233 §B)', async () => {
|
|
151
|
+
const pre = await preflight();
|
|
152
|
+
if ('kind' in pre) return softSkip(pre.kind, pre.reason);
|
|
153
|
+
if (!providerReadAdvertised(pre.connections)) return softSkip('inapplicable', 'the host does not advertise connections.providerRead (RFC 0233): no registry read to check');
|
|
154
|
+
const out = recordNormative((doc, m) => req('openwop.requirement.0233.provider-registry.unique', doc, m), await uniqueLeg(v2Validator('connection-provider-registry')));
|
|
155
|
+
if (out !== 'observed') return softSkip(out.kind, out.reason);
|
|
156
|
+
noteWitnessPaths(true, false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** The seam path for fail-closed. `absent` when the install seam is unwired. */
|
|
161
|
+
async function seamFailClosed(): Promise<SeamLeg> {
|
|
102
162
|
const first = await installFixture();
|
|
103
|
-
if (!first) return
|
|
163
|
+
if (!first) return 'absent';
|
|
104
164
|
// The branch is kept for a host that somehow ships a built-in of this id:
|
|
105
165
|
// the fixture is then the LATER registration and the rule fires on the
|
|
106
166
|
// first install. `acme-widgets` is fictional so the normal path is the
|
|
@@ -115,19 +175,19 @@ describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
|
|
|
115
175
|
})();
|
|
116
176
|
expect(later?.installed, req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, 'the later registration of a bare provider id MUST NOT install (no version precedence)')).toBe(false);
|
|
117
177
|
expect(codes(later), req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, 'the later registration MUST be refused with connection_provider_conflict')).toContain('connection_provider_conflict');
|
|
118
|
-
|
|
178
|
+
return 'ran';
|
|
179
|
+
}
|
|
119
180
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (skip) return softSkip(skip.kind, skip.reason);
|
|
181
|
+
/** The seam path for the qualified form. `absent` when the install seam is unwired. */
|
|
182
|
+
async function seamQualified(): Promise<SeamLeg> {
|
|
123
183
|
const first = await installFixture();
|
|
124
|
-
if (!first) return
|
|
125
|
-
if (first.res?.installed !== true) return
|
|
184
|
+
if (!first) return 'absent';
|
|
185
|
+
if (first.res?.installed !== true) return { kind: 'blocked', reason: `the fixture did not install (${codes(first.res).join(',') || first.status}) — a host with a built-in acme-widgets cannot exercise the qualified form through the pack` };
|
|
126
186
|
const packName = fixture().name;
|
|
127
187
|
const hit = await driver.post(RESOLVE, { provider: `${packName}#acme-widgets` });
|
|
128
|
-
if (hit.status === 404) return
|
|
188
|
+
if (hit.status === 404) return { kind: 'blocked', reason: 'RFC 0095 resolve seam not mounted (404)' };
|
|
129
189
|
const resolved = hit.json as ResolveResult | undefined;
|
|
130
190
|
expect(resolved?.resolved, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', `${packName}#acme-widgets MUST resolve (got ${JSON.stringify(resolved)})`)).toBe(true);
|
|
131
191
|
expect(resolved?.source, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', 'a qualified reference resolves only to the named pack\'s definition (source: pack)')).toBe('pack');
|
|
132
|
-
|
|
133
|
-
}
|
|
192
|
+
return 'ran';
|
|
193
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the trigger bridge's delivery model on the normative surface
|
|
3
|
+
* (`spec/v2/core/webhooks.md` §Inbound triggers). The v1 twin is
|
|
4
|
+
* `trigger-bridge-delivery`; this file shares its requirement ids, as the v2
|
|
5
|
+
* secrets witness does, so a requirement is one id at every major. The legs
|
|
6
|
+
* live in `lib/trigger-delivery-witness.ts`; what differs between majors is the
|
|
7
|
+
* profile row in `lib/major-profile.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Unaided: the suite registers a `webhook` subscription and posts Standard
|
|
10
|
+
* Webhooks-signed bodies to its `ingestUrl` with no OpenWOP credential.
|
|
11
|
+
*
|
|
12
|
+
* 1. dedup a repeated `webhook-id` is effectively-once.
|
|
13
|
+
* 2. dead-letter a refused post starts no run and leaves the subscription
|
|
14
|
+
* `active`; a correctly signed post still delivers.
|
|
15
|
+
* 2b. stale timestamp under `required`, a `webhook-timestamp` more than
|
|
16
|
+
* 300 s off, either way, answers `401 signature_invalid`,
|
|
17
|
+
* starts no run and leaves the subscription `active`
|
|
18
|
+
* (RFC 0230 §C).
|
|
19
|
+
* 2c. secret once a re-read of the subscription carries no `signingSecret`
|
|
20
|
+
* and no `whsec_` value (RFC 0230 §B); `inapplicable` where
|
|
21
|
+
* the host serves no re-read.
|
|
22
|
+
* 3. causation the delivered run's `run.started` carries the delivery.
|
|
23
|
+
* 4a. run-less attempt a dead-lettered attempt is content-free, read through
|
|
24
|
+
* `GET /trigger-subscriptions/{subscriptionId}/dead-letters`.
|
|
25
|
+
* 4b. run-less state change `inapplicable`: no wire surface causes one and
|
|
26
|
+
* no seam drives one at major 2 (RFC 0232 §E, gap G2).
|
|
27
|
+
*
|
|
28
|
+
* Dispositions: `triggerBridge` absent, or `ingestion.inboundSigning` not
|
|
29
|
+
* listing `standard-webhooks-1` ⇒ `inapplicable` (major 2 has no other delivery
|
|
30
|
+
* surface). Leg 4a also needs `triggerBridge.deadLetter`.
|
|
31
|
+
*
|
|
32
|
+
* Proven against a scratch double in `lib/trigger-delivery-witness.test.ts`.
|
|
33
|
+
*
|
|
34
|
+
* @see spec/v2/core/webhooks.md §Inbound triggers
|
|
35
|
+
* @see RFCS/0230-inbound-webhook-ingest-contract.md, RFCS/0232-trigger-dead-letter-read.md
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, expect } from 'vitest';
|
|
39
|
+
import { familyAdvertised, v2Discovery, v2Validator } from '../lib/v2.js';
|
|
40
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
41
|
+
import { req } from '../lib/requirement-ids.js';
|
|
42
|
+
import { noteObservation } from '../lib/row-observation.js';
|
|
43
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
44
|
+
import { adverts, causationLeg, dedupLeg, refusedLeg, runlessAttemptLeg, runlessStateChangeLeg, signingSecretOnceLeg, staleTimestampLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
|
|
45
|
+
|
|
46
|
+
const PROFILE = majorProfile(2);
|
|
47
|
+
const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
|
|
48
|
+
const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
|
|
49
|
+
const R_STALE_TIMESTAMP = 'openwop.requirement.0230.stale-timestamp-refused';
|
|
50
|
+
const R_SECRET_ONCE = 'openwop.requirement.0230.signing-secret-once';
|
|
51
|
+
const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
|
|
52
|
+
const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
|
|
53
|
+
const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `familyAdvertised`, not `gateFamily`: `triggerBridge` is optional, and a host that does not advertise it
|
|
57
|
+
* has taken on no obligation here. `gateFamily` fails an unadvertised family in strict mode (the 2.45.7
|
|
58
|
+
* `v2-workspace-scope-from-identity` defect).
|
|
59
|
+
*/
|
|
60
|
+
async function discovered(): Promise<TriggerAdverts | string> {
|
|
61
|
+
if (!(await familyAdvertised('triggerBridge'))) return 'GATED';
|
|
62
|
+
let doc: Record<string, unknown> | null;
|
|
63
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
64
|
+
return doc ? adverts(PROFILE, doc) : 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('v2 trigger-bridge delivery (webhooks.md §Inbound triggers)', () => {
|
|
68
|
+
it('de-dups a repeated webhook-id: effectively-once', async () => {
|
|
69
|
+
const a = await discovered();
|
|
70
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
71
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
72
|
+
const out = await dedupLeg(PROFILE, a);
|
|
73
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
74
|
+
for (const x of out.findings) expect(x.ok, req(R_DEDUP, x.doc, x.message)).toBe(true);
|
|
75
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('a refused post starts no run and leaves the subscription active', async () => {
|
|
79
|
+
const a = await discovered();
|
|
80
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
81
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
82
|
+
const out = await refusedLeg(PROFILE, a);
|
|
83
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
84
|
+
for (const x of out.findings) expect(x.ok, req(R_DEAD_LETTER, x.doc, x.message)).toBe(true);
|
|
85
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refuses a webhook-timestamp more than 300 s off without starting a run', async () => {
|
|
89
|
+
const a = await discovered();
|
|
90
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
91
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
92
|
+
const out = await staleTimestampLeg(PROFILE, a);
|
|
93
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
94
|
+
for (const x of out.findings) expect(x.ok, req(R_STALE_TIMESTAMP, x.doc, x.message)).toBe(true);
|
|
95
|
+
noteObservation('normative-surface path (RFC 0230)');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns the signing secret once: a re-read carries no whsec_ value', async () => {
|
|
99
|
+
const a = await discovered();
|
|
100
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
101
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
102
|
+
const out = await signingSecretOnceLeg(PROFILE, a);
|
|
103
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
104
|
+
for (const x of out.findings) expect(x.ok, req(R_SECRET_ONCE, x.doc, x.message)).toBe(true);
|
|
105
|
+
noteObservation('normative-surface path (RFC 0230)');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('links delivery to run: run.started carries the delivery as causationId', async () => {
|
|
109
|
+
const a = await discovered();
|
|
110
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
111
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
112
|
+
const out = await causationLeg(PROFILE, a);
|
|
113
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
114
|
+
for (const x of out.findings) expect(x.ok, req(R_CAUSATION, x.doc, x.message)).toBe(true);
|
|
115
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('keeps a dead-lettered run-less attempt content-free', async () => {
|
|
119
|
+
const a = await discovered();
|
|
120
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
121
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
122
|
+
const out = await runlessAttemptLeg(PROFILE, a, v2Validator('trigger-dead-letter-page'));
|
|
123
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
124
|
+
for (const x of out.findings) expect(x.ok, req(R_RUNLESS_ATTEMPT, x.doc, x.message)).toBe(true);
|
|
125
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('keeps a run-less subscription state change content-free', async () => {
|
|
129
|
+
const a = await discovered();
|
|
130
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
131
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
132
|
+
const out = runlessStateChangeLeg(a);
|
|
133
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
134
|
+
expect(out.kind, req(R_RUNLESS_STATE, 'webhooks.md §Inbound triggers', 'unreachable: no major-2 surface causes a state change')).toBe('skip');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the trigger dead-letter read's own rules (RFC 0232 §B;
|
|
3
|
+
* `spec/v2/core/webhooks.md` §Inbound triggers). The v1 twin is
|
|
4
|
+
* `trigger-dead-letter-read`; the requirement ids are shared, and the legs live
|
|
5
|
+
* in `lib/trigger-delivery-witness.ts`.
|
|
6
|
+
*
|
|
7
|
+
* paging `limit` is clamped, and a short page carries `nextCursor`.
|
|
8
|
+
* cursor-bound a cursor minted for one subscription is refused on another,
|
|
9
|
+
* `400 validation_error`.
|
|
10
|
+
* tenant-bound another tenant's bound id is refused `403 id_tenant_mismatch`
|
|
11
|
+
* and discloses nothing (`identity.md` §5; at v1, with bare ids,
|
|
12
|
+
* the same rule is `404` as for an id never minted). An id never
|
|
13
|
+
* minted answers `404`. Needs `OPENWOP_TEST_TENANT_B_API_KEY`,
|
|
14
|
+
* else `blocked`.
|
|
15
|
+
*
|
|
16
|
+
* Dispositions: `triggerBridge`, `triggerBridge.deadLetter` or
|
|
17
|
+
* `ingestion.inboundSigning` not advertised ⇒ `inapplicable`.
|
|
18
|
+
*
|
|
19
|
+
* Proven against a scratch double in `lib/trigger-delivery-witness.test.ts`.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/webhooks.md §Inbound triggers
|
|
22
|
+
* @see RFCS/0232-trigger-dead-letter-read.md §B
|
|
23
|
+
* @see spec/v2/core/identity.md §5
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { familyAdvertised, v2Discovery } from '../lib/v2.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
import { loadEnv } from '../lib/env.js';
|
|
31
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
32
|
+
import { adverts, cursorLeg, pagingLeg, tenantLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
|
|
33
|
+
|
|
34
|
+
const PROFILE = majorProfile(2);
|
|
35
|
+
const R_PAGING = 'openwop.requirement.0232.trigger-dead-letters.paging';
|
|
36
|
+
const R_CURSOR = 'openwop.requirement.0232.trigger-dead-letters.cursor-bound';
|
|
37
|
+
const R_TENANT = 'openwop.requirement.0232.trigger-dead-letters.tenant-bound';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `familyAdvertised`, not `gateFamily`: `triggerBridge` is optional, and a host that does not advertise it
|
|
41
|
+
* has taken on no obligation here. `gateFamily` fails an unadvertised family in strict mode (the 2.45.7
|
|
42
|
+
* `v2-workspace-scope-from-identity` defect).
|
|
43
|
+
*/
|
|
44
|
+
async function discovered(): Promise<TriggerAdverts | string> {
|
|
45
|
+
if (!(await familyAdvertised('triggerBridge'))) return 'GATED';
|
|
46
|
+
let doc: Record<string, unknown> | null;
|
|
47
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
48
|
+
return doc ? adverts(PROFILE, doc) : 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('v2 trigger dead-letter read (RFC 0232 §B)', () => {
|
|
52
|
+
it('clamps limit and continues a short page with nextCursor', async () => {
|
|
53
|
+
const a = await discovered();
|
|
54
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
55
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
56
|
+
const out = await pagingLeg(PROFILE, a);
|
|
57
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
58
|
+
for (const x of out.findings) expect(x.ok, req(R_PAGING, x.doc, x.message)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('refuses a cursor minted for another subscription', async () => {
|
|
62
|
+
const a = await discovered();
|
|
63
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
64
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
65
|
+
const out = await cursorLeg(PROFILE, a);
|
|
66
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
67
|
+
for (const x of out.findings) expect(x.ok, req(R_CURSOR, x.doc, x.message)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("refuses another tenant's bound id 403 id_tenant_mismatch and discloses nothing", async () => {
|
|
71
|
+
const a = await discovered();
|
|
72
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
73
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
74
|
+
const out = await tenantLeg(PROFILE, a, process.env['OPENWOP_TEST_TENANT_B_API_KEY'], loadEnv().apiKey);
|
|
75
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
76
|
+
for (const x of out.findings) expect(x.ok, req(R_TENANT, x.doc, x.message)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|