@openwop/openwop-conformance 2.37.0 → 2.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +80 -28
- package/schemas/CORPUS-STAMP.json +11 -11
- package/src/lib/front-mux.ts +8 -1
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
|
@@ -103,14 +103,14 @@
|
|
|
103
103
|
*/
|
|
104
104
|
|
|
105
105
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
106
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
106
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
107
107
|
import { createHmac } from 'node:crypto';
|
|
108
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
109
108
|
import { driver } from '../lib/driver.js';
|
|
110
109
|
import { discoveryFamilies } from '../lib/discovery-capabilities.js';
|
|
111
110
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
112
111
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
113
|
-
import { discoverOwnedTenant
|
|
112
|
+
import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
|
|
113
|
+
import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
114
114
|
import { req } from '../lib/requirement-ids.js';
|
|
115
115
|
|
|
116
116
|
interface DeliveredRequest {
|
|
@@ -118,44 +118,43 @@ interface DeliveredRequest {
|
|
|
118
118
|
readonly body: string;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
async function startReceiver(): Promise<
|
|
121
|
+
async function startReceiver(): Promise<ScopedReceiver & { received: DeliveredRequest[] }> {
|
|
122
122
|
const received: DeliveredRequest[] = [];
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
received.push({ headers, body });
|
|
134
|
-
res.writeHead(204);
|
|
135
|
-
res.end();
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
// Port 0 (ephemeral) by default — nothing outside this process needs to find
|
|
139
|
-
// it. But OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel,
|
|
140
|
-
// and a tunnel has to be pointed at a port the operator knows in ADVANCE. An
|
|
123
|
+
// `startScopedReceiver` (2.37.0) supplies the listening, the pinned-port
|
|
124
|
+
// binding described below, the public front, AND a nonce path that makes this
|
|
125
|
+
// exercise's destination its own. Four webhook files registered the SAME
|
|
126
|
+
// byte-identical front URL before that, and a webhook subscription outlives
|
|
127
|
+
// the file that made it — so a sibling's retries (one of those files answers
|
|
128
|
+
// 500 by design) arrived here indistinguishable from this run's deliveries.
|
|
129
|
+
//
|
|
130
|
+
// The pinned port is still honoured, and still for the reason it was added:
|
|
131
|
+
// OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel, and a
|
|
132
|
+
// tunnel has to be pointed at a port the operator knows in ADVANCE. An
|
|
141
133
|
// ephemeral port makes that variable unusable by anyone not reading the port
|
|
142
134
|
// out of a running process — a gap found by standing up a real TLS front and
|
|
143
|
-
// trying to use the feature, not by reading the code.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
// trying to use the feature, not by reading the code.
|
|
136
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
137
|
+
const headers: Record<string, string> = {};
|
|
138
|
+
for (const [k, v] of Object.entries(hit.headers)) {
|
|
139
|
+
if (typeof v === 'string') headers[k.toLowerCase()] = v;
|
|
140
|
+
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(',');
|
|
141
|
+
}
|
|
142
|
+
received.push({ headers, body: hit.body });
|
|
143
|
+
res.writeHead(204);
|
|
144
|
+
res.end();
|
|
145
|
+
});
|
|
146
|
+
return { ...rx, received };
|
|
152
147
|
}
|
|
153
148
|
|
|
154
|
-
|
|
149
|
+
// Closed through the receiver: `close()` also drops this exercise's nonce from
|
|
150
|
+
// the front-mux registry, so a delivery that arrives after the leg has finished
|
|
151
|
+
// is answered 404 rather than handed to the next exercise's recorder.
|
|
152
|
+
let activeServer: ScopedReceiver | null = null;
|
|
155
153
|
afterEach(async () => {
|
|
156
154
|
if (activeServer) {
|
|
157
|
-
|
|
155
|
+
const rx = activeServer;
|
|
158
156
|
activeServer = null;
|
|
157
|
+
await rx.close();
|
|
159
158
|
}
|
|
160
159
|
});
|
|
161
160
|
|
|
@@ -179,7 +178,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
const receiver = await startReceiver();
|
|
182
|
-
activeServer = receiver
|
|
181
|
+
activeServer = receiver;
|
|
183
182
|
|
|
184
183
|
// Register the webhook.
|
|
185
184
|
// webhooks.md §Register: `events` + `tenantId` are REQUIRED (empty events → 400).
|
|
@@ -190,9 +189,12 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
190
189
|
// tenantId is 403'd by a host that scopes subscriptions by membership
|
|
191
190
|
// (RFC 0093). Single-tenant hosts return undefined ⇒ omit tenantId.
|
|
192
191
|
const ownedTenant = await discoverOwnedTenant(driver);
|
|
193
|
-
|
|
192
|
+
// `receiver.url` is this exercise's own destination — the public front (when
|
|
193
|
+
// wired) plus this receiver's nonce path. It is no longer run through
|
|
194
|
+
// `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
|
|
195
|
+
// the path that makes the subscription ours.
|
|
194
196
|
const reg = await driver.post('/v1/webhooks', {
|
|
195
|
-
url:
|
|
197
|
+
url: receiver.url,
|
|
196
198
|
events: ['run.completed'],
|
|
197
199
|
...(ownedTenant ? { tenantId: ownedTenant } : {}),
|
|
198
200
|
});
|
|
@@ -212,7 +214,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
212
214
|
if (reg.status === 400) {
|
|
213
215
|
const body = reg.json as { error?: string };
|
|
214
216
|
if (body.error === 'webhook_url_rejected') {
|
|
215
|
-
if (!
|
|
217
|
+
if (!receiver.tunnelled) {
|
|
216
218
|
// eslint-disable-next-line no-console
|
|
217
219
|
console.warn(
|
|
218
220
|
'[webhook-signed-delivery] host SSRF guard rejected the loopback receiver; ' +
|
|
@@ -222,7 +224,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
222
224
|
return softSkip('blocked', 'precondition not met — `body.error === \'webhook_url_rejected\'` returned early (seam, prior step, or fixture unavailable)');
|
|
223
225
|
}
|
|
224
226
|
expect.fail(
|
|
225
|
-
`host rejected the operator-supplied public https receiver (${
|
|
227
|
+
`host rejected the operator-supplied public https receiver (${receiver.url}) with ` +
|
|
226
228
|
'webhook_url_rejected. A public https destination is legitimate under ' +
|
|
227
229
|
'webhooks.md §"SSRF protection" and RFC 0093 §"Delivery-time egress validation"; ' +
|
|
228
230
|
'rejecting it is a host defect, not an unmet precondition.',
|
|
@@ -307,15 +309,26 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
307
309
|
// has a subscriber; if nothing arrived HERE, the delivery went somewhere
|
|
308
310
|
// this process cannot see and every assertion below it would be vacuous.
|
|
309
311
|
// It fails — it must never soft-skip.
|
|
312
|
+
//
|
|
313
|
+
// The ONE exception, and it is not a softening (2.37.0): when other traffic
|
|
314
|
+
// DID reach this listener, the path from the host to this process demonstrably
|
|
315
|
+
// works, so a zero is the absence of this exercise's IDENTITY and not of
|
|
316
|
+
// delivery — unmeasured, not unmet. That is recorded `blocked`, which denies
|
|
317
|
+
// certification exactly as a failure does (RFC 0168 §E.1), so the mis-wired
|
|
318
|
+
// front this assertion guards against still cannot read as a pass; a
|
|
319
|
+
// mis-wired front produces no traffic here at all and still fails below.
|
|
320
|
+
if (ourDeliveries.length === 0 && absenceIsUnmeasured(receiver)) {
|
|
321
|
+
return blockedDespiteAssertions(noDeliveryCause(receiver, 'run event for this run'));
|
|
322
|
+
}
|
|
310
323
|
expect(ourDeliveries.length, req('openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature',
|
|
311
324
|
'webhooks.md §"Delivery"',
|
|
312
|
-
|
|
325
|
+
receiver.tunnelled
|
|
313
326
|
? 'host MUST POST at least one event for THIS run to the registered subscriber. ' +
|
|
314
327
|
'Registration was accepted, so zero deliveries observed on the local receiver means ' +
|
|
315
328
|
'either the host did not deliver, or OPENWOP_WEBHOOK_RECEIVER_URL does not actually ' +
|
|
316
|
-
|
|
329
|
+
`front this process. Both are failures; neither is a skip. ${noDeliveryCause(receiver, 'run event for this run')}`
|
|
317
330
|
: 'host MUST POST at least one event for THIS run to a registered subscriber within '
|
|
318
|
-
+ `${DELIVERY_DEADLINE_MS}ms of run.completed`,
|
|
331
|
+
+ `${DELIVERY_DEADLINE_MS}ms of run.completed. ${noDeliveryCause(receiver, 'run event for this run')}`,
|
|
319
332
|
)).toBeGreaterThan(0);
|
|
320
333
|
|
|
321
334
|
// Validate the FIRST delivery's signature contract. Other deliveries
|
|
@@ -418,7 +431,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
418
431
|
}
|
|
419
432
|
|
|
420
433
|
const receiver = await startReceiver();
|
|
421
|
-
activeServer = receiver
|
|
434
|
+
activeServer = receiver;
|
|
422
435
|
|
|
423
436
|
const ownedTenant = await discoverOwnedTenant(driver);
|
|
424
437
|
const reg = await driver.post('/v1/webhooks', {
|
package/src/setup.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { basename, join } from 'node:path';
|
|
|
36
36
|
import { existsSync, readFileSync } from 'node:fs';
|
|
37
37
|
import { PKG_ROOT_PATH } from './lib/paths.js';
|
|
38
38
|
import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
39
|
-
import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState } from './lib/scenario-disposition.js';
|
|
39
|
+
import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState, type TestFailure } from './lib/scenario-disposition.js';
|
|
40
40
|
import { softSkipDisposition, softSkipDispositionSince, softSkipMark } from './lib/soft-skip.js';
|
|
41
41
|
import { ItIdAllocator, takeExplicitRequirementId } from './lib/requirement-ids.js';
|
|
42
42
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-coherence.js';
|
|
@@ -248,6 +248,8 @@ await maybeStartA2AFakePeer();
|
|
|
248
248
|
// Setup-file hooks apply to every file in the worker; state is keyed by file.
|
|
249
249
|
const _fileStates = new Map<string, FileTestState[]>();
|
|
250
250
|
const _fileAssertions = new Map<string, number>();
|
|
251
|
+
/** Per file: the failing cases, so the file row can NAME them (2.37.0). */
|
|
252
|
+
const _fileFailures = new Map<string, TestFailure[]>();
|
|
251
253
|
const _ledgerMarks = new Map<string, number>();
|
|
252
254
|
// Per-`it` recording (v2 charter Phase 1, suite 1.153.0 — the durable G8 fix
|
|
253
255
|
// named in scenario-disposition.ts). Each test gets its own ledger row under
|
|
@@ -415,6 +417,17 @@ afterEach(({ task }) => {
|
|
|
415
417
|
const arr = _fileStates.get(file) ?? [];
|
|
416
418
|
arr.push(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip');
|
|
417
419
|
_fileStates.set(file, arr);
|
|
420
|
+
// Which leg failed, and why (2.37.0). The file row used to say only "one or
|
|
421
|
+
// more assertions in the file failed" — a detail that names no case, quotes no
|
|
422
|
+
// message, and cannot be acted on. A tier-2 operator read exactly that row for
|
|
423
|
+
// `v2-run-bulk-cancel` and had to hand-probe every assertion in the file
|
|
424
|
+
// against production to find out what the host had done.
|
|
425
|
+
if (state === 'fail') {
|
|
426
|
+
const msg = ((task.result?.errors ?? [])[0] as { message?: string } | undefined)?.message;
|
|
427
|
+
const fails = _fileFailures.get(file) ?? [];
|
|
428
|
+
fails.push({ name: task.name, ...(msg === undefined ? {} : { message: msg }) });
|
|
429
|
+
_fileFailures.set(file, fails);
|
|
430
|
+
}
|
|
418
431
|
// RFC 0148 §C assertionCount: how many `expect` calls this test actually made.
|
|
419
432
|
// A leg that early-returns from a gate makes zero, and a file of such legs is
|
|
420
433
|
// an `executed-pass` with assertionCount 0 — visible, and unclassified for a
|
|
@@ -470,13 +483,19 @@ afterEach(({ task }) => {
|
|
|
470
483
|
// `inapplicable` came out `blocked` — which denies certification bundle-wide).
|
|
471
484
|
const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
|
|
472
485
|
const err = (task.result?.errors ?? [])[0] as { message?: string } | undefined;
|
|
473
|
-
const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
|
|
486
|
+
const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2, task.name);
|
|
474
487
|
disposition = rec.disposition;
|
|
475
488
|
detail = rec.detail;
|
|
476
489
|
}
|
|
477
490
|
try {
|
|
478
491
|
const evidence = takeNotedEvidence();
|
|
479
|
-
|
|
492
|
+
// `fold: true` — several `it` legs of one file legitimately share ONE
|
|
493
|
+
// explicit id (27 files do, via a module-level `const ID` handed to
|
|
494
|
+
// `req()`). Without the fold the second leg's verdict hit
|
|
495
|
+
// `recordRequirement`'s one-disposition-per-run throw, was swallowed by the
|
|
496
|
+
// catch below, and never reached the JSONL sink: a file could record
|
|
497
|
+
// `executed-fail` while its own requirement row read `executed-pass`.
|
|
498
|
+
recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file, fold: true, ...(evidence === null ? {} : { evidence }) });
|
|
480
499
|
} catch {
|
|
481
500
|
/* never fail a test for bookkeeping */
|
|
482
501
|
}
|
|
@@ -503,7 +522,7 @@ afterAll(({}, suite) => {
|
|
|
503
522
|
// the marker detail — never to a pass. Floors still REJECT that row, so the
|
|
504
523
|
// honest bundle row and the pressure to say why both survive. The rule is
|
|
505
524
|
// `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
|
|
506
|
-
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file);
|
|
525
|
+
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file, _fileFailures.get(file) ?? []);
|
|
507
526
|
const fileRequirementId = requirementIdForFile(file);
|
|
508
527
|
// A scenario that classified ITSELF wins outright — including its `detail` and
|
|
509
528
|
// its `assertionCount`.
|
|
@@ -530,6 +549,7 @@ afterAll(({}, suite) => {
|
|
|
530
549
|
}
|
|
531
550
|
_fileStates.delete(file);
|
|
532
551
|
_fileAssertions.delete(file);
|
|
552
|
+
_fileFailures.delete(file);
|
|
533
553
|
_ledgerMarks.delete(file);
|
|
534
554
|
_itAllocators.delete(file);
|
|
535
555
|
_itMarks.delete(file);
|