@openwop/openwop-conformance 1.21.0 → 1.23.0
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 +43 -2
- package/README.md +61 -63
- package/api/asyncapi.yaml +54 -38
- package/api/openapi.yaml +34 -6
- package/coverage.md +381 -202
- package/fixtures/connection-packs/connection-pack-github.json +31 -0
- package/fixtures.md +120 -101
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +49 -0
- package/schemas/connection-pack-manifest.schema.json +161 -0
- package/schemas/run-event-payloads.schema.json +6 -5
- package/schemas/run-event.schema.json +11 -2
- package/schemas/run-options.schema.json +1 -2
- package/schemas/run-snapshot.schema.json +2 -1
- package/schemas/suspend-request.schema.json +5 -0
- package/src/scenarios/connection-pack-manifest-valid.test.ts +122 -0
- package/src/scenarios/connection-pack-no-credential-material.test.ts +125 -0
- package/src/scenarios/connection-pack-reach-exclusive.test.ts +85 -0
- package/src/scenarios/connection-pack-write-reconsent.test.ts +91 -0
- package/src/scenarios/connection-provider-resolution.test.ts +153 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +3 -3
- package/src/scenarios/fixtures-valid.test.ts +34 -0
- package/src/scenarios/grpc-transport.test.ts +108 -0
- package/src/scenarios/i18n-negotiation.test.ts +181 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +2 -2
- package/src/scenarios/media-url-inline-cap.test.ts +5 -3
- package/src/scenarios/spec-corpus-validity.test.ts +107 -0
- package/src/scenarios/stream-text-fixture.test.ts +212 -0
- package/src/scenarios/version-fold.test.ts +193 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -2
- package/src/scenarios/webhook-tenant-isolation.test.ts +184 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-fold — `version-negotiation.md` §Cross-version interop matrix,
|
|
3
|
+
* exercised via the test-keys-only `X-Force-Engine-Version` header
|
|
4
|
+
* (§"Conformance via `X-Force-Engine-Version`"). Consumer of the
|
|
5
|
+
* `conformance-version-fold` fixture (closes catalog gap F5 — the fixture
|
|
6
|
+
* previously had no consuming scenario; see `fixtures.md`).
|
|
7
|
+
*
|
|
8
|
+
* Gating: `describe.skipIf` on the advertised `conformance-version-fold`
|
|
9
|
+
* fixture id (RFC 0003). The forced-version seam itself is advertised via
|
|
10
|
+
* `Capabilities.testing.forceEngineVersionRange = { min, max }` — hosts
|
|
11
|
+
* that advertise the fixture but not the seam soft-skip with a warning
|
|
12
|
+
* (mirroring the suite's other test-seam scenarios, e.g. the OTel
|
|
13
|
+
* collector seams), because the header is a test seam, not a production
|
|
14
|
+
* surface.
|
|
15
|
+
*
|
|
16
|
+
* What's asserted per the fixture driver (`fixtures.md` §`conformance-version-fold`):
|
|
17
|
+
* 1. For each forced version in `[min, current, max]` (deduped), a run
|
|
18
|
+
* of the single-noop fixture reaches terminal `completed`.
|
|
19
|
+
* 2. `GET /v1/runs/{runId}` returns a readable `RunSnapshot` (the
|
|
20
|
+
* projection tolerates the version mismatch via fold-best-effort).
|
|
21
|
+
* 3. The event log is readable via `GET /v1/runs/{runId}/events/poll`
|
|
22
|
+
* and non-empty.
|
|
23
|
+
* 4. Negative: a forced version outside the advertised range returns
|
|
24
|
+
* `400 unsupported_force_engine_version`.
|
|
25
|
+
*
|
|
26
|
+
* NOT black-box testable here: the production-key refusal
|
|
27
|
+
* (`403 force_engine_version_forbidden`) — the suite holds a single API
|
|
28
|
+
* key, which must be a test key for the positive legs to run at all.
|
|
29
|
+
* When the host answers `403 force_engine_version_forbidden` the key is
|
|
30
|
+
* production-scoped and the scenario soft-skips honestly.
|
|
31
|
+
*
|
|
32
|
+
* @see spec/v1/version-negotiation.md §"Conformance via `X-Force-Engine-Version`"
|
|
33
|
+
* @see conformance/fixtures.md §`conformance-version-fold`
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
38
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
39
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
40
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
41
|
+
|
|
42
|
+
const FIXTURE_ID = 'conformance-version-fold';
|
|
43
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(FIXTURE_ID);
|
|
44
|
+
|
|
45
|
+
interface ForceRange {
|
|
46
|
+
readonly min: number;
|
|
47
|
+
readonly max: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read `testing.forceEngineVersionRange` + the host's current `engineVersion`
|
|
51
|
+
* from discovery. Returns null when the host doesn't expose the seam. */
|
|
52
|
+
async function readForceSeam(): Promise<{ range: ForceRange; current: number | null } | null> {
|
|
53
|
+
const res = await driver.get('/.well-known/openwop');
|
|
54
|
+
if (res.status !== 200) return null;
|
|
55
|
+
const testing = capabilityFamily<{ forceEngineVersionRange?: unknown }>(res.json, 'testing');
|
|
56
|
+
const range = testing?.forceEngineVersionRange as { min?: unknown; max?: unknown } | undefined;
|
|
57
|
+
if (!range || !Number.isInteger(range.min) || !Number.isInteger(range.max)) return null;
|
|
58
|
+
const engineVersion = capabilityFamily<unknown>(res.json, 'engineVersion');
|
|
59
|
+
return {
|
|
60
|
+
range: { min: range.min as number, max: range.max as number },
|
|
61
|
+
current: Number.isInteger(engineVersion) ? (engineVersion as number) : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errCode(json: unknown): string | undefined {
|
|
66
|
+
const e = (json as { error?: unknown } | undefined)?.error;
|
|
67
|
+
return typeof e === 'string' ? e : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function createForcedRun(version: number): Promise<OpenWOPResponse> {
|
|
71
|
+
return driver.post(
|
|
72
|
+
'/v1/runs',
|
|
73
|
+
{ workflowId: FIXTURE_ID },
|
|
74
|
+
{ headers: { 'X-Force-Engine-Version': String(version) } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe.skipIf(SKIP_NO_FIXTURE)('version-fold: forced engine versions fold-best-effort across the advertised range', () => {
|
|
79
|
+
it('each version in [min, current, max] completes + snapshot and event log stay readable', async () => {
|
|
80
|
+
const seam = await readForceSeam();
|
|
81
|
+
if (seam === null) {
|
|
82
|
+
// eslint-disable-next-line no-console
|
|
83
|
+
console.warn(
|
|
84
|
+
'[version-fold] host does not advertise Capabilities.testing.forceEngineVersionRange; ' +
|
|
85
|
+
'skipping (the X-Force-Engine-Version seam is test-keys-only and optional)',
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const candidates = [seam.range.min, ...(seam.current === null ? [] : [seam.current]), seam.range.max];
|
|
91
|
+
const versions = [...new Set(candidates)].filter(
|
|
92
|
+
(v) => v >= seam.range.min && v <= seam.range.max,
|
|
93
|
+
);
|
|
94
|
+
expect(
|
|
95
|
+
versions.length,
|
|
96
|
+
driver.describe(
|
|
97
|
+
'version-negotiation.md §Conformance via X-Force-Engine-Version',
|
|
98
|
+
'forceEngineVersionRange MUST describe a non-empty integer range (min <= max)',
|
|
99
|
+
),
|
|
100
|
+
).toBeGreaterThan(0);
|
|
101
|
+
|
|
102
|
+
for (const v of versions) {
|
|
103
|
+
const create = await createForcedRun(v);
|
|
104
|
+
if (create.status === 403 && errCode(create.json) === 'force_engine_version_forbidden') {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.warn('[version-fold] API key is production-scoped (403 force_engine_version_forbidden); skipping');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
expect(
|
|
110
|
+
create.status,
|
|
111
|
+
driver.describe(
|
|
112
|
+
'version-negotiation.md §Conformance via X-Force-Engine-Version',
|
|
113
|
+
`POST /v1/runs with X-Force-Engine-Version: ${v} (within the advertised range) MUST be accepted on a test key`,
|
|
114
|
+
),
|
|
115
|
+
).toBe(201);
|
|
116
|
+
const runId = (create.json as { runId: string }).runId;
|
|
117
|
+
|
|
118
|
+
// 1) Terminal completed — the fold tolerates the forced version.
|
|
119
|
+
const terminal = await pollUntilTerminal(runId);
|
|
120
|
+
expect(
|
|
121
|
+
terminal.status,
|
|
122
|
+
driver.describe(
|
|
123
|
+
'version-negotiation.md §Cross-version interop matrix',
|
|
124
|
+
`the conformance-version-fold noop run MUST complete under forced engine version ${v} (fold-best-effort)`,
|
|
125
|
+
),
|
|
126
|
+
).toBe('completed');
|
|
127
|
+
|
|
128
|
+
// 2) The projection (RunSnapshot) is readable.
|
|
129
|
+
const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
130
|
+
expect(
|
|
131
|
+
snap.status,
|
|
132
|
+
driver.describe(
|
|
133
|
+
'version-negotiation.md §Cross-version interop matrix',
|
|
134
|
+
`GET /v1/runs/{runId} MUST return a readable RunSnapshot under forced engine version ${v}`,
|
|
135
|
+
),
|
|
136
|
+
).toBe(200);
|
|
137
|
+
const snapBody = snap.json as { runId?: unknown; status?: unknown };
|
|
138
|
+
expect(typeof snapBody.runId).toBe('string');
|
|
139
|
+
expect(typeof snapBody.status).toBe('string');
|
|
140
|
+
|
|
141
|
+
// 3) The event log is readable + non-empty.
|
|
142
|
+
const events = await driver.get(
|
|
143
|
+
`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
|
|
144
|
+
);
|
|
145
|
+
expect(
|
|
146
|
+
events.status,
|
|
147
|
+
driver.describe(
|
|
148
|
+
'version-negotiation.md §Conformance via X-Force-Engine-Version',
|
|
149
|
+
`the event log MUST be readable via events/poll under forced engine version ${v}`,
|
|
150
|
+
),
|
|
151
|
+
).toBe(200);
|
|
152
|
+
const eventList = (events.json as { events?: unknown[] } | undefined)?.events ?? [];
|
|
153
|
+
expect(
|
|
154
|
+
eventList.length,
|
|
155
|
+
driver.describe(
|
|
156
|
+
'version-negotiation.md §Conformance via X-Force-Engine-Version',
|
|
157
|
+
`events/poll MUST return a non-empty events[] for the forced-version run (v=${v})`,
|
|
158
|
+
),
|
|
159
|
+
).toBeGreaterThan(0);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('a forced version outside the advertised range is rejected with 400 unsupported_force_engine_version', async () => {
|
|
164
|
+
const seam = await readForceSeam();
|
|
165
|
+
if (seam === null) {
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.warn('[version-fold] forceEngineVersionRange not advertised; skipping negative leg');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const outOfRange = seam.range.max + 1;
|
|
172
|
+
const create = await createForcedRun(outOfRange);
|
|
173
|
+
if (create.status === 403 && errCode(create.json) === 'force_engine_version_forbidden') {
|
|
174
|
+
// eslint-disable-next-line no-console
|
|
175
|
+
console.warn('[version-fold] API key is production-scoped; skipping negative leg');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
expect(
|
|
179
|
+
create.status,
|
|
180
|
+
driver.describe(
|
|
181
|
+
'version-negotiation.md §Conformance via X-Force-Engine-Version',
|
|
182
|
+
`X-Force-Engine-Version: ${outOfRange} (outside the advertised range) MUST return 400`,
|
|
183
|
+
),
|
|
184
|
+
).toBe(400);
|
|
185
|
+
expect(
|
|
186
|
+
errCode(create.json),
|
|
187
|
+
driver.describe(
|
|
188
|
+
'version-negotiation.md §Conformance via X-Force-Engine-Version',
|
|
189
|
+
'the out-of-range refusal MUST carry the canonical unsupported_force_engine_version code',
|
|
190
|
+
),
|
|
191
|
+
).toBe('unsupported_force_engine_version');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -80,13 +80,15 @@ describe('wasm-pack-memory-cap: positive path via misbehaving pack', () => {
|
|
|
80
80
|
)).toBe('failed');
|
|
81
81
|
|
|
82
82
|
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
83
|
-
const list = (events.json as { events?: Array<{ type: string;
|
|
83
|
+
const list = (events.json as { events?: Array<{ type: string; payload?: unknown }> }).events ?? [];
|
|
84
84
|
const breachEvent = list.find((e) => e.type === 'cap.breached');
|
|
85
85
|
expect(breachEvent, driver.describe(
|
|
86
86
|
'RFCS/0008-wasm-abi.md §K',
|
|
87
87
|
'host MUST emit cap.breached when a WASM module exceeds its memory ceiling',
|
|
88
88
|
)).toBeDefined();
|
|
89
|
-
|
|
89
|
+
// Event detail rides under the canonical `payload` envelope field (per
|
|
90
|
+
// run-event-payloads.schema.json + every other event scenario), not `data`.
|
|
91
|
+
const breachKind = (breachEvent?.payload as { kind?: string } | undefined)?.kind;
|
|
90
92
|
expect(breachKind, driver.describe(
|
|
91
93
|
'RFCS/0008-wasm-abi.md §K',
|
|
92
94
|
'cap.breached payload MUST carry kind: "wasm-memory" for memory-ceiling breaches',
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhook-tenant-isolation — RFC 0093 §A.3 + SECURITY/invariants.yaml
|
|
3
|
+
* `webhook-cross-tenant-isolation`.
|
|
4
|
+
*
|
|
5
|
+
* Status: ACTIVE (advertisement + behavioral). RFC 0093 §A.3: a webhook
|
|
6
|
+
* subscription MUST receive only events from runs within the
|
|
7
|
+
* subscription's tenant scope (the tenant established at registration
|
|
8
|
+
* per the webhooks.md registration-time membership gate); cross-tenant
|
|
9
|
+
* delivery is a protocol violation regardless of filter breadth.
|
|
10
|
+
*
|
|
11
|
+
* Delivery itself is not black-box observable from a single-tenant
|
|
12
|
+
* conformance run (RFC 0093 §Conformance) — the delivery half is carried
|
|
13
|
+
* by the reference-impl-tier `webhook-delivery-egress-revalidation`
|
|
14
|
+
* pointers. What this scenario proves, mirroring the existing
|
|
15
|
+
* cross-tenant-isolation pattern (`kv-cross-tenant-isolation.test.ts`):
|
|
16
|
+
*
|
|
17
|
+
* - Seam-driven two-tenant proof through the optional reference-host
|
|
18
|
+
* test seam `POST /v1/host/sample/test/surface` (`surface: "webhooks"`,
|
|
19
|
+
* ops `register` / `list` / `unregister`): a subscription registered
|
|
20
|
+
* under tenant A MUST NOT appear in tenant B's subscription list.
|
|
21
|
+
* Hosts that don't expose the seam (HTTP 404) soft-skip this half.
|
|
22
|
+
*
|
|
23
|
+
* - Black-box single-tenant proxy assertions on the registration
|
|
24
|
+
* surface (`webhooks.md` §Register/§Unregister): registering under a
|
|
25
|
+
* tenant the caller is not a member of is refused, and a held
|
|
26
|
+
* subscription cannot be unregistered through a foreign tenant scope.
|
|
27
|
+
*
|
|
28
|
+
* Gated via `behaviorGate('openwop-webhook-tenant-isolation',
|
|
29
|
+
* webhooks.supported === true)` — soft-skip by default, hard-fail under
|
|
30
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` (root-first family read per RFC 0073).
|
|
31
|
+
*
|
|
32
|
+
* @see RFCS/0093-protocol-hardening-webhooks-tokens-idempotency.md §A.3
|
|
33
|
+
* @see spec/v1/webhooks.md §Endpoints (registration-time membership gate)
|
|
34
|
+
* @see SECURITY/invariants.yaml — webhook-cross-tenant-isolation
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, it, expect } from 'vitest';
|
|
38
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
39
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
40
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
41
|
+
|
|
42
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
43
|
+
|
|
44
|
+
interface WebhooksCap {
|
|
45
|
+
readonly supported?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A public, registration-safe delivery URL (https:// per webhooks.md;
|
|
49
|
+
* resolves publicly so SSRF guards don't reject it). Never delivered to
|
|
50
|
+
* in this scenario — registration only. */
|
|
51
|
+
const SAFE_URL = 'https://example.com/openwop-conformance/webhook-tenant-isolation';
|
|
52
|
+
|
|
53
|
+
function uniqueSuffix(): string {
|
|
54
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function seam(tenantId: string, op: string, args: Record<string, unknown>): Promise<OpenWOPResponse> {
|
|
58
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId, surface: 'webhooks', op, args });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function gateOnWebhooks(): Promise<boolean> {
|
|
62
|
+
const cap = await readCapabilityFamily<WebhooksCap>('webhooks');
|
|
63
|
+
return behaviorGate('openwop-webhook-tenant-isolation', cap?.supported === true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe.skipIf(HTTP_SKIP)('webhook-tenant-isolation: advertisement shape (webhooks.md)', () => {
|
|
67
|
+
it('the webhooks block is either absent or carries a boolean supported flag', async () => {
|
|
68
|
+
const cap = await readCapabilityFamily<WebhooksCap>('webhooks');
|
|
69
|
+
if (cap === undefined) return; // host doesn't advertise webhooks — conformant
|
|
70
|
+
expect(
|
|
71
|
+
typeof cap.supported,
|
|
72
|
+
driver.describe('capabilities.schema.json §webhooks', 'webhooks.supported MUST be a boolean when present'),
|
|
73
|
+
).toBe('boolean');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe.skipIf(HTTP_SKIP)('webhook-tenant-isolation: two-tenant proof via the test seam (RFC 0093 §A.3)', () => {
|
|
78
|
+
it('a subscription registered under tenant A is invisible to tenant B', async () => {
|
|
79
|
+
if (!(await gateOnWebhooks())) return;
|
|
80
|
+
|
|
81
|
+
const reg = await seam('tenant-a', 'register', {
|
|
82
|
+
url: SAFE_URL,
|
|
83
|
+
events: ['run.completed'],
|
|
84
|
+
});
|
|
85
|
+
if (reg.status === 404) return; // host doesn't expose the test seam — soft-skip
|
|
86
|
+
expect(reg.status, driver.describe('RFC 0093 §A.3', 'seam register under tenant A MUST succeed')).toBe(200);
|
|
87
|
+
const webhookId = (reg.json as { webhookId?: string }).webhookId;
|
|
88
|
+
expect(typeof webhookId, 'seam register MUST return the new webhookId').toBe('string');
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const listB = await seam('tenant-b', 'list', {});
|
|
92
|
+
expect(listB.status).toBe(200);
|
|
93
|
+
const idsB = ((listB.json as { webhooks?: Array<{ webhookId?: string }> }).webhooks ?? [])
|
|
94
|
+
.map((w) => w.webhookId);
|
|
95
|
+
expect(
|
|
96
|
+
idsB,
|
|
97
|
+
driver.describe(
|
|
98
|
+
'SECURITY/invariants.yaml webhook-cross-tenant-isolation',
|
|
99
|
+
'tenant B MUST NOT see tenant A subscriptions in its list',
|
|
100
|
+
),
|
|
101
|
+
).not.toContain(webhookId);
|
|
102
|
+
|
|
103
|
+
const listA = await seam('tenant-a', 'list', {});
|
|
104
|
+
const idsA = ((listA.json as { webhooks?: Array<{ webhookId?: string }> }).webhooks ?? [])
|
|
105
|
+
.map((w) => w.webhookId);
|
|
106
|
+
expect(
|
|
107
|
+
idsA,
|
|
108
|
+
driver.describe('RFC 0093 §A.3', 'tenant A MUST see its own subscription (same-tenant control)'),
|
|
109
|
+
).toContain(webhookId);
|
|
110
|
+
} finally {
|
|
111
|
+
await seam('tenant-a', 'unregister', { webhookId });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe.skipIf(HTTP_SKIP)('webhook-tenant-isolation: black-box registration-surface scoping (webhooks.md)', () => {
|
|
117
|
+
it('registering under a tenant the caller is not a member of is refused', async () => {
|
|
118
|
+
if (!(await gateOnWebhooks())) return;
|
|
119
|
+
|
|
120
|
+
const foreignTenant = `openwop-conformance-foreign-${uniqueSuffix()}`;
|
|
121
|
+
const reg = await driver.post('/v1/webhooks', {
|
|
122
|
+
url: SAFE_URL,
|
|
123
|
+
events: ['run.completed'],
|
|
124
|
+
tenantId: foreignTenant,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (reg.status === 201) {
|
|
128
|
+
// Leaked registration — clean up before failing the assertion below.
|
|
129
|
+
const body = reg.json as { webhookId?: string };
|
|
130
|
+
if (body.webhookId) {
|
|
131
|
+
await driver.delete(
|
|
132
|
+
`/v1/webhooks/${encodeURIComponent(body.webhookId)}?tenantId=${encodeURIComponent(foreignTenant)}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
expect(
|
|
137
|
+
reg.status,
|
|
138
|
+
driver.describe(
|
|
139
|
+
'webhooks.md §Endpoints ("the caller MUST be a member of the tenant") + RFC 0093 §A.3',
|
|
140
|
+
'registration under a tenant the caller is not a member of MUST be refused (403/404/400), never 201',
|
|
141
|
+
),
|
|
142
|
+
).not.toBe(201);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('a held subscription cannot be unregistered through a foreign tenant scope', async () => {
|
|
146
|
+
if (!(await gateOnWebhooks())) return;
|
|
147
|
+
|
|
148
|
+
// Register under the caller's own tenant (host defaults the scope from
|
|
149
|
+
// the API key when tenantId is omitted — the same registration shape the
|
|
150
|
+
// webhook delivery scenarios use).
|
|
151
|
+
const reg = await driver.post('/v1/webhooks', {
|
|
152
|
+
url: SAFE_URL,
|
|
153
|
+
events: ['run.completed'],
|
|
154
|
+
});
|
|
155
|
+
if (reg.status !== 201) {
|
|
156
|
+
// Host requires an explicit tenantId (or rejected the public URL) —
|
|
157
|
+
// nothing to hold; the membership-gate leg above still applies.
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.warn(
|
|
160
|
+
`[webhook-tenant-isolation] could not register a probe subscription (${reg.status}); skipping foreign-unregister leg`,
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const webhookId = (reg.json as { webhookId?: string }).webhookId;
|
|
165
|
+
expect(typeof webhookId, 'registration MUST return webhookId').toBe('string');
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const foreignTenant = `openwop-conformance-foreign-${uniqueSuffix()}`;
|
|
169
|
+
const del = await driver.delete(
|
|
170
|
+
`/v1/webhooks/${encodeURIComponent(webhookId!)}?tenantId=${encodeURIComponent(foreignTenant)}`,
|
|
171
|
+
);
|
|
172
|
+
expect(
|
|
173
|
+
del.status !== 204 && del.status < 500,
|
|
174
|
+
driver.describe(
|
|
175
|
+
'webhooks.md §Unregister (403 non-member / 404 not-in-scope) + RFC 0093 §A.3',
|
|
176
|
+
`unregister through a foreign tenant scope MUST NOT succeed (got ${del.status})`,
|
|
177
|
+
),
|
|
178
|
+
).toBe(true);
|
|
179
|
+
} finally {
|
|
180
|
+
// Best-effort cleanup in the caller's own scope.
|
|
181
|
+
await driver.delete(`/v1/webhooks/${encodeURIComponent(webhookId!)}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|