@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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-provider-conflict — RFC 0177 §D.1; `spec/v2/core/connection-packs.md`
|
|
3
|
+
* §"Provider identity" and §"The qualified form".
|
|
4
|
+
*
|
|
5
|
+
* Suite 2.0.0, target major 2. A `provider.id` MUST be unique per host. When an
|
|
6
|
+
* installed pack and a built-in, or two installed packs, define the same bare
|
|
7
|
+
* id the host MUST refuse the later registration with
|
|
8
|
+
* `connection_provider_conflict` (409 in `spec/v2/errors.json`); version-based
|
|
9
|
+
* precedence is gone. A connector MAY name a provider by the qualified form
|
|
10
|
+
* `<packName>#<id>`, which resolves only to the named pack's definition.
|
|
11
|
+
*
|
|
12
|
+
* Seam-gated: driven through the RFC 0095 install/resolve seams
|
|
13
|
+
* (`POST /v1/host/sample/connection-packs/{install,resolve}`,
|
|
14
|
+
* `spec/v1/host-sample-test-seams.md` §10; the driver rewrites the v1 seam path
|
|
15
|
+
* to `/conformance/seams/sample/…` under target major 2), gated on
|
|
16
|
+
* `connections.packsSupported`.
|
|
17
|
+
*
|
|
18
|
+
* 1. install the `connection-pack-github` fixture (engines rewritten to admit
|
|
19
|
+
* major 2), then a second pack claiming bare `github` → the later one is
|
|
20
|
+
* refused with connection_provider_conflict. A host that ships a built-in
|
|
21
|
+
* `github` witnesses the same rule on the FIRST install.
|
|
22
|
+
* 2. `resolve { provider: "<packName>#github" }` → resolved, source: pack.
|
|
23
|
+
*
|
|
24
|
+
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §D.1
|
|
25
|
+
* @see spec/v2/core/connection-packs.md
|
|
26
|
+
* @see spec/v1/host-sample-test-seams.md §10
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
33
|
+
import { driver } from '../lib/driver.js';
|
|
34
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
37
|
+
import { targetMajor } from '../lib/seams.js';
|
|
38
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
39
|
+
|
|
40
|
+
const SECTION = 'connection-packs.md §"Provider identity" (RFC 0177 §D.1)';
|
|
41
|
+
const FIXTURE = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
|
|
42
|
+
const INSTALL = '/v1/host/sample/connection-packs/install';
|
|
43
|
+
const RESOLVE = '/v1/host/sample/connection-packs/resolve';
|
|
44
|
+
|
|
45
|
+
type Manifest = Record<string, unknown> & { name: string; provider: Record<string, unknown> & { id: string } };
|
|
46
|
+
interface InstallResult { installed?: boolean; errors?: Array<{ code?: string; path?: string }> }
|
|
47
|
+
interface ResolveResult { resolved?: boolean; source?: 'pack' | 'builtin'; version?: string; code?: string }
|
|
48
|
+
|
|
49
|
+
function fixture(): Manifest {
|
|
50
|
+
const m = JSON.parse(readFileSync(FIXTURE, 'utf8')) as Manifest;
|
|
51
|
+
// The v1 fixture pins `>=1.0.0`; a v2 host reads that as `<2.0.0` (RFC 0177 §A.1).
|
|
52
|
+
return { ...m, engines: { openwop: '>=2.0.0 <3.0.0' } };
|
|
53
|
+
}
|
|
54
|
+
function conflicting(base: Manifest): Manifest {
|
|
55
|
+
return { ...base, name: `core.openwop.v2-provider-conflict-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` };
|
|
56
|
+
}
|
|
57
|
+
function codes(r: InstallResult | undefined): string[] {
|
|
58
|
+
return (r?.errors ?? []).map((e) => e.code ?? '').filter((c) => c.length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
62
|
+
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
63
|
+
let doc: Record<string, unknown> | null;
|
|
64
|
+
try {
|
|
65
|
+
doc = await v2Discovery();
|
|
66
|
+
} catch {
|
|
67
|
+
doc = null;
|
|
68
|
+
}
|
|
69
|
+
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
70
|
+
const connections = await familyAdvertised('connections');
|
|
71
|
+
if (!behaviorGate('connections.packsSupported', connections?.['packsSupported'] === true)) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise connections.packsSupported (RFC 0095 §C)' };
|
|
72
|
+
if (!existsSync(FIXTURE)) return { kind: 'blocked', reason: 'fixture connection-packs/connection-pack-github.json is absent from this layout' };
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Install the fixture; null when the seam is unwired. */
|
|
77
|
+
async function installFixture(): Promise<{ res: InstallResult | undefined; status: number } | null> {
|
|
78
|
+
const res = await driver.post(INSTALL, { manifest: fixture() });
|
|
79
|
+
if (res.status === 404 || res.status === 403) return null;
|
|
80
|
+
return { res: res.json as InstallResult | undefined, status: res.status };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
|
|
84
|
+
it('two definitions of one bare provider id fail closed: the later registration is refused with connection_provider_conflict', async () => {
|
|
85
|
+
const skip = await preflight();
|
|
86
|
+
if (skip) return softSkip(skip.kind, skip.reason);
|
|
87
|
+
const first = await installFixture();
|
|
88
|
+
if (!first) return softSkip('blocked', 'RFC 0095 install seam not mounted (404/403) — connections.packsSupported advertised but /conformance/seams/sample/connection-packs/install is absent');
|
|
89
|
+
// A host that ships a built-in `github` makes the fixture the LATER
|
|
90
|
+
// registration, so the rule fires on the first install; otherwise the
|
|
91
|
+
// fixture installs and a second pack claiming bare `github` is the later one.
|
|
92
|
+
const later = first.res?.installed === false && codes(first.res).includes('connection_provider_conflict')
|
|
93
|
+
? first.res
|
|
94
|
+
: await (async () => {
|
|
95
|
+
expect(first.res?.installed, req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, `the well-formed fixture MUST install when no other definition of github exists (got ${first.status}: ${JSON.stringify(first.res)})`)).toBe(true);
|
|
96
|
+
return (await driver.post(INSTALL, { manifest: conflicting(fixture()) })).json as InstallResult | undefined;
|
|
97
|
+
})();
|
|
98
|
+
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);
|
|
99
|
+
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');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('the qualified form <packName>#github resolves to the named pack\'s definition', async () => {
|
|
103
|
+
const skip = await preflight();
|
|
104
|
+
if (skip) return softSkip(skip.kind, skip.reason);
|
|
105
|
+
const first = await installFixture();
|
|
106
|
+
if (!first) return softSkip('blocked', 'RFC 0095 install seam not mounted (404/403) — connections.packsSupported advertised but /conformance/seams/sample/connection-packs/install is absent');
|
|
107
|
+
if (first.res?.installed !== true) return softSkip('blocked', `the fixture did not install (${codes(first.res).join(',') || first.status}) — a host with a built-in github cannot exercise the qualified form through the pack`);
|
|
108
|
+
const packName = fixture().name;
|
|
109
|
+
const hit = await driver.post(RESOLVE, { provider: `${packName}#github` });
|
|
110
|
+
if (hit.status === 404) return softSkip('blocked', 'RFC 0095 resolve seam not mounted (404)');
|
|
111
|
+
const resolved = hit.json as ResolveResult | undefined;
|
|
112
|
+
expect(resolved?.resolved, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', `${packName}#github MUST resolve (got ${JSON.stringify(resolved)})`)).toBe(true);
|
|
113
|
+
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');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §D.4 — `refresh-sla` (suite 2.0.0, target major 2; unaided, gated on a2a or mcp).
|
|
3
|
+
*
|
|
4
|
+
* A host MUST re-evaluate its advertised `versions[]` / `revisions[]` against
|
|
5
|
+
* the upstream registry within the window its `refreshedAt` declares, and that
|
|
6
|
+
* window MUST NOT exceed 90 days; an advertisement older than its window is
|
|
7
|
+
* non-conformant (`spec/v2/core/interop.md` §Negotiation is a protocol — The
|
|
8
|
+
* refresh SLA; RFC 0147 R10; RFC 0175 row C8.4).
|
|
9
|
+
*
|
|
10
|
+
* `refreshedAt` is a `date` (`spec/v2/facets/a2a|mcp.schema.json`), REQUIRED on
|
|
11
|
+
* both facets. The check is against the suite's clock: `now - refreshedAt ≤ 90
|
|
12
|
+
* days`, and not in the future (a refresh date ahead of the clock is a claim
|
|
13
|
+
* about a check that has not happened). The preferred version and the floor
|
|
14
|
+
* MUST be members of the offered set, or the advertisement names something a
|
|
15
|
+
* peer cannot act on.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v2/core/interop.md §Negotiation is a protocol
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
22
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
23
|
+
import { req } from '../lib/requirement-ids.js';
|
|
24
|
+
|
|
25
|
+
const WINDOW_DAYS = 90;
|
|
26
|
+
const DAY_MS = 86_400_000;
|
|
27
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
|
+
|
|
29
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
30
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('RFC 0175 §D.4 — refresh-sla (unaided; gated on a2a or mcp)', () => {
|
|
34
|
+
it('refreshedAt on each embedded-protocol facet is within 90 days of now', async () => {
|
|
35
|
+
const doc = await discovery();
|
|
36
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
37
|
+
const facets: Array<{ family: 'a2a' | 'mcp'; offered: string; preferred: string; floor: string }> = [
|
|
38
|
+
{ family: 'a2a', offered: 'versions', preferred: 'preferredVersion', floor: 'minimumVersion' },
|
|
39
|
+
{ family: 'mcp', offered: 'revisions', preferred: 'preferredVersion', floor: 'minimumRevision' },
|
|
40
|
+
];
|
|
41
|
+
let checked = 0;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const f of facets) {
|
|
44
|
+
const rec = await familyAdvertised(f.family);
|
|
45
|
+
if (!rec) continue;
|
|
46
|
+
checked++;
|
|
47
|
+
const refreshedAt = rec['refreshedAt'];
|
|
48
|
+
expect(
|
|
49
|
+
typeof refreshedAt === 'string' && DATE.test(refreshedAt),
|
|
50
|
+
req('openwop.requirement.0175.refresh-sla', `facets/${f.family}.schema.json refreshedAt`, `${f.family}.refreshedAt is REQUIRED and MUST be a date (got ${JSON.stringify(refreshedAt)})`),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
const at = Date.parse(`${String(refreshedAt)}T00:00:00Z`);
|
|
53
|
+
const ageDays = (now - at) / DAY_MS;
|
|
54
|
+
expect(
|
|
55
|
+
ageDays,
|
|
56
|
+
req('openwop.requirement.0175.refresh-sla', 'interop.md §The refresh SLA', `${f.family}.refreshedAt (${String(refreshedAt)}) MUST be within ${WINDOW_DAYS} days of now — an advertisement older than its window is non-conformant (RFC 0175 §D.4)`),
|
|
57
|
+
).toBeLessThanOrEqual(WINDOW_DAYS);
|
|
58
|
+
expect(
|
|
59
|
+
ageDays,
|
|
60
|
+
req('openwop.requirement.0175.refresh-sla', 'interop.md §The refresh SLA', `${f.family}.refreshedAt (${String(refreshedAt)}) MUST NOT be in the future — a refresh that has not happened is not a refresh`),
|
|
61
|
+
).toBeGreaterThanOrEqual(-1);
|
|
62
|
+
const offered = Array.isArray(rec[f.offered]) ? (rec[f.offered] as unknown[]) : [];
|
|
63
|
+
expect(
|
|
64
|
+
offered.length,
|
|
65
|
+
req('openwop.requirement.0175.refresh-sla', `facets/${f.family}.schema.json ${f.offered}`, `${f.family}.${f.offered}[] MUST offer at least one entry`),
|
|
66
|
+
).toBeGreaterThan(0);
|
|
67
|
+
expect(
|
|
68
|
+
offered,
|
|
69
|
+
req('openwop.requirement.0175.refresh-sla', 'interop.md §The facets', `${f.family}.${f.preferred} MUST be one of the offered ${f.offered}[]`),
|
|
70
|
+
).toContain(rec[f.preferred]);
|
|
71
|
+
expect(
|
|
72
|
+
offered,
|
|
73
|
+
req('openwop.requirement.0175.refresh-sla', 'interop.md §The facets', `${f.family}.${f.floor} MUST be one of the offered ${f.offered}[] — a floor below every offer refuses nothing`),
|
|
74
|
+
).toContain(rec[f.floor]);
|
|
75
|
+
}
|
|
76
|
+
if (checked === 0) return softSkip('inapplicable', 'host advertises neither a2a nor mcp — no refreshedAt to check');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §A.2 — `relaxation-recorded` (suite 2.0.0, target major 2; unaided).
|
|
3
|
+
*
|
|
4
|
+
* A relaxation is an operator setting recorded in the certification bundle
|
|
5
|
+
* (`host.relaxations[]`, bundle v3) with a durability class from a closed set,
|
|
6
|
+
* and "a bundle that records a relaxation cannot certify the profile the
|
|
7
|
+
* relaxed obligation belongs to" (`spec/v2/core/security-defaults.md`
|
|
8
|
+
* §Relaxations; RFC 0173 §A.2, row C6.8). Both halves are witnessable without a
|
|
9
|
+
* host: the suite builds a bundle v3 fixture and runs the verifier and the
|
|
10
|
+
* schema over it. No discovery fetch, no seam.
|
|
11
|
+
*
|
|
12
|
+
* Two legs:
|
|
13
|
+
* 1. `verifyBundleV3` rejects `relaxed-profile-certified` when a claimed
|
|
14
|
+
* profile is `certified: true` while a relaxation on one of its
|
|
15
|
+
* obligations is recorded — with a positive control (the same bundle
|
|
16
|
+
* without the relaxation certifies) so the rejection is attributable to
|
|
17
|
+
* the relaxation rather than to some other defect of the fixture.
|
|
18
|
+
* 2. `durability` outside `session | deployment | persisted` fails
|
|
19
|
+
* `schemas/v2/certification-bundle.schema.json`; every member of the
|
|
20
|
+
* closed set validates.
|
|
21
|
+
*
|
|
22
|
+
* @see spec/v2/core/security-defaults.md §Relaxations
|
|
23
|
+
* @see RFCS/0173-v2-security-defaults.md §A.2
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
28
|
+
import { signBundleV3, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Relaxation } from '../lib/certification-bundle-v3.js';
|
|
29
|
+
import { v2Validator } from '../lib/v2.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
|
|
32
|
+
const host = generateKeyPairSync('ed25519');
|
|
33
|
+
const hostPem = host.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
34
|
+
|
|
35
|
+
const ROWS: BundleV3['results']['requirements'] = [
|
|
36
|
+
{ id: 'openwop.requirement.0173.webhook-durable-delivery', scenario: 'v2-webhook-durable-delivery.test.ts', result: 'executed-pass', assertions: 4 },
|
|
37
|
+
{ id: 'openwop.requirement.0169.capabilities-root-closed', scenario: 'v2-capabilities-root-closed.test.ts', result: 'executed-pass', assertions: 3 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** A schema-valid, verifier-clean bundle v3; `relaxations` and the claimed profile vary per leg. */
|
|
41
|
+
function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }): BundleV3 {
|
|
42
|
+
const unsigned: Omit<BundleV3, 'signature'> = {
|
|
43
|
+
bundleVersion: '3',
|
|
44
|
+
generatedAt: '2026-09-03T00:00:00Z',
|
|
45
|
+
suite: { name: '@openwop/openwop-conformance', version: '2.0.0', targetMajor: 2, specArtifactsVersion: '2.0.0' },
|
|
46
|
+
host: {
|
|
47
|
+
name: 'fixture-host',
|
|
48
|
+
version: '2.0.0',
|
|
49
|
+
build: { kind: 'commit', id: 'deadbeef' },
|
|
50
|
+
signingKeyId: 'host-key-1',
|
|
51
|
+
...(opts.relaxations ? { relaxations: opts.relaxations } : {}),
|
|
52
|
+
},
|
|
53
|
+
discovery: { url: 'https://fixture.invalid/.well-known/openwop', sha256: 'a'.repeat(64), protocolVersions: ['2.0'], preferredVersion: '2.0' },
|
|
54
|
+
claimedProfiles: [
|
|
55
|
+
{ id: 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
|
|
56
|
+
{ id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
|
|
57
|
+
],
|
|
58
|
+
results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
|
|
59
|
+
witnessSha256: witnessDigest(ROWS),
|
|
60
|
+
assertionCount: ROWS.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
61
|
+
};
|
|
62
|
+
return { ...unsigned, signature: signBundleV3(unsigned, hostPem, 'host-key-1') };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const RELAXED: BundleV3Relaxation = { obligation: 'webhooks.durable-delivery', durability: 'deployment', reason: 'development deployment; no delivery queue' };
|
|
66
|
+
|
|
67
|
+
describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', () => {
|
|
68
|
+
it('a bundle recording a relaxation cannot certify the relaxed profile', () => {
|
|
69
|
+
// Positive control first: without the relaxation the same bundle certifies
|
|
70
|
+
// `openwop-webhooks`, so the rejection below is the relaxation's, not the fixture's.
|
|
71
|
+
const control = verifyBundleV3(bundle({ certified: true }));
|
|
72
|
+
expect(
|
|
73
|
+
control.rejections,
|
|
74
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'control: a bundle with no relaxation and a fully witnessed profile MUST verify clean'),
|
|
75
|
+
).toEqual([]);
|
|
76
|
+
expect(
|
|
77
|
+
control.certifiedProfiles,
|
|
78
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'control: the unrelaxed profile certifies'),
|
|
79
|
+
).toContain('openwop-webhooks');
|
|
80
|
+
|
|
81
|
+
const relaxed = verifyBundleV3(bundle({ relaxations: [RELAXED], certified: true }));
|
|
82
|
+
expect(
|
|
83
|
+
relaxed.rejections.map((r) => r.kind),
|
|
84
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'a bundle that records a relaxation MUST NOT certify the profile the relaxed obligation belongs to (`relaxed-profile-certified`, RFC 0173 §A.2)'),
|
|
85
|
+
).toContain('relaxed-profile-certified');
|
|
86
|
+
expect(
|
|
87
|
+
relaxed.rejections.find((r) => r.kind === 'relaxed-profile-certified')?.profile,
|
|
88
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'the rejection names the relaxed profile'),
|
|
89
|
+
).toBe('openwop-webhooks');
|
|
90
|
+
expect(
|
|
91
|
+
relaxed.certifiedProfiles,
|
|
92
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'the relaxed profile is not in `certifiedProfiles`'),
|
|
93
|
+
).not.toContain('openwop-webhooks');
|
|
94
|
+
// A relaxation on one obligation does not poison an unrelated profile.
|
|
95
|
+
expect(
|
|
96
|
+
relaxed.certifiedProfiles,
|
|
97
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'an unrelated profile still certifies — the relaxation is scoped to the obligation it names'),
|
|
98
|
+
).toContain('openwop-discovery-core');
|
|
99
|
+
|
|
100
|
+
// A host that records the relaxation AND does not claim the profile is the honest shape.
|
|
101
|
+
const honest = verifyBundleV3(bundle({ relaxations: [RELAXED], certified: false }));
|
|
102
|
+
expect(
|
|
103
|
+
honest.rejections.map((r) => r.kind),
|
|
104
|
+
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'a relaxation recorded against an unclaimed profile is not a rejection — recording is the obligation, claiming is the defect'),
|
|
105
|
+
).not.toContain('relaxed-profile-certified');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('durability is a closed set: session | deployment | persisted', () => {
|
|
109
|
+
const validate = v2Validator('certification-bundle');
|
|
110
|
+
const withDurability = (durability: string): unknown => {
|
|
111
|
+
const b = bundle({ relaxations: [{ ...RELAXED, durability: durability as BundleV3Relaxation['durability'] }], certified: false });
|
|
112
|
+
// The verifier fixture is schema-shaped; hand it to Ajv as plain data.
|
|
113
|
+
return JSON.parse(JSON.stringify(b));
|
|
114
|
+
};
|
|
115
|
+
for (const d of ['session', 'deployment', 'persisted']) {
|
|
116
|
+
const r = validate(withDurability(d));
|
|
117
|
+
expect(
|
|
118
|
+
r.ok,
|
|
119
|
+
req('openwop.requirement.0173.relaxation-recorded.durability-closed', 'certification-bundle.schema.json host.relaxations[].durability', `durability "${d}" is a member of the closed set and MUST validate: ${r.errors}`),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
}
|
|
122
|
+
for (const d of ['permanent', 'runtime', '']) {
|
|
123
|
+
const r = validate(withDurability(d));
|
|
124
|
+
expect(
|
|
125
|
+
r.ok,
|
|
126
|
+
req('openwop.requirement.0173.relaxation-recorded.durability-closed', 'certification-bundle.schema.json host.relaxations[].durability', `durability "${d}" is outside session | deployment | persisted and MUST fail the bundle schema (RFC 0173 §A.2)`),
|
|
127
|
+
).toBe(false);
|
|
128
|
+
expect(
|
|
129
|
+
r.errors,
|
|
130
|
+
req('openwop.requirement.0173.relaxation-recorded.durability-closed', 'certification-bundle.schema.json host.relaxations[].durability', 'the failure names the durability enum'),
|
|
131
|
+
).toMatch(/durability|enum/);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `revocation-honored` (suite 2.0.0; RFC 0170 §B.3;
|
|
3
|
+
* `spec/v2/core/identity.md` §2.2 "Trust roots and revocation").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: seam-gated (`openwop-conformance-seams-v2`, advertised as
|
|
6
|
+
* `conformance.seamsProfile`). Revocation exists for every lane; for the two
|
|
7
|
+
* `next-request` lanes (`api-key`, `session`) a revoked credential MUST be
|
|
8
|
+
* refused on the very next request with `401 credential_revoked`. The
|
|
9
|
+
* per-lane revoke seam RFC 0170 names ("the §20 seam family grows one leg per
|
|
10
|
+
* lane") is not yet in `spec/v1/host-sample-test-seams.md`; this scenario
|
|
11
|
+
* drives `POST /conformance/seams/sample/auth/credential/{mint,revoke}` (the
|
|
12
|
+
* v1-shaped address `/v1/host/sample/auth/credential/…` through `seamPath()`)
|
|
13
|
+
* with `{ lane }` / `{ lane, credential }` and records `blocked` when the host
|
|
14
|
+
* does not mount it. Windowed lanes (`exp-and-recheck`, `short-lived`, `rebind`)
|
|
15
|
+
* have no wire-observable "next request" and record `inapplicable`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
20
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
21
|
+
import { seamPath, seamsProfileAdvertised } from '../lib/seams.js';
|
|
22
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
23
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
|
|
26
|
+
const DOC = 'spec/v2/core/identity.md §2.2';
|
|
27
|
+
const MINT = seamPath('/v1/host/sample/auth/credential/mint');
|
|
28
|
+
const REVOKE = seamPath('/v1/host/sample/auth/credential/revoke');
|
|
29
|
+
|
|
30
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
31
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
34
|
+
try { return await fn(); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('v2 revocation-honored (RFC 0170 §B.3 — seam-gated)', () => {
|
|
38
|
+
it('a revoked next-request credential is refused on the next request with credential_revoked', async () => {
|
|
39
|
+
const doc = await discovery();
|
|
40
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
41
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — revocation is seam-gated and cannot be observed from the canonical API');
|
|
42
|
+
const auth = await familyAdvertised('auth');
|
|
43
|
+
const lanes = Array.isArray(auth?.['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : [];
|
|
44
|
+
const nextRequest = lanes.filter((l) => l['revocation'] === 'next-request').map((l) => String(l['lane']));
|
|
45
|
+
if (nextRequest.length === 0) return softSkip('inapplicable', 'no advertised lane uses revocation: next-request (api-key / session) — windowed lanes have no wire-observable next request');
|
|
46
|
+
|
|
47
|
+
for (const lane of nextRequest) {
|
|
48
|
+
const minted = await http(() => driver.post(MINT, { lane }));
|
|
49
|
+
if (minted === null) return softSkip('blocked', `${MINT} unreachable (fetch failed)`);
|
|
50
|
+
if (minted.status === 404 || minted.status === 403) return seamAbsent(`${MINT} not mounted (${minted.status}) — the per-lane revoke seam RFC 0170 §B.3 names is not specified in host-sample-test-seams.md and this host does not serve it`);
|
|
51
|
+
const credential = (minted.json as { credential?: unknown } | undefined)?.credential;
|
|
52
|
+
expect(typeof credential, req('openwop.requirement.0170.revocation-honored', DOC, `the mint seam MUST answer { credential } for lane ${lane}`)).toBe('string');
|
|
53
|
+
|
|
54
|
+
// Positive control — the minted credential authenticates before revocation.
|
|
55
|
+
const before = await http(() => driver.get('/runs/openwop-conformance-tenant/revocationprobe0123456789', { authenticated: false, headers: { Authorization: `Bearer ${String(credential)}` } }));
|
|
56
|
+
expect(before !== null && before.status !== 401, req('openwop.requirement.0170.revocation-honored', DOC, `a freshly minted ${lane} credential MUST authenticate before revocation (got ${before?.status ?? 'no response'})`)).toBe(true);
|
|
57
|
+
|
|
58
|
+
const revoked = await http(() => driver.post(REVOKE, { lane, credential }));
|
|
59
|
+
expect(revoked !== null && revoked.status < 400, req('openwop.requirement.0170.revocation-honored', DOC, `the revoke seam MUST accept the credential it minted for lane ${lane} (got ${revoked?.status ?? 'no response'})`)).toBe(true);
|
|
60
|
+
|
|
61
|
+
const after = await http(() => driver.get('/runs/openwop-conformance-tenant/revocationprobe0123456789', { authenticated: false, headers: { Authorization: `Bearer ${String(credential)}` } }));
|
|
62
|
+
expect(after?.status, req('openwop.requirement.0170.revocation-honored', DOC, `lane ${lane} MUST refuse a revoked credential on the next request with 401`)).toBe(401);
|
|
63
|
+
expect(readErrorCode(after?.json), req('openwop.requirement.0170.revocation-honored', DOC, `the refusal code MUST be credential_revoked (lane ${lane})`)).toBe('credential_revoked');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `subject-link-record` (suite 2.0.0; RFC 0170 §C.1;
|
|
3
|
+
* `spec/v2/core/identity.md` §3 "The link is a record";
|
|
4
|
+
* `schemas/v2/subject-link.schema.json`).
|
|
5
|
+
*
|
|
6
|
+
* Witness class: seam-gated (`openwop-conformance-seams-v2`). Advertising both
|
|
7
|
+
* the `saml` and `scim` lanes implies the linking contract. Through the RFC
|
|
8
|
+
* 0163 seams (`POST /conformance/seams/sample/auth/scim/provision`,
|
|
9
|
+
* `POST /conformance/seams/sample/auth/saml/validate` — the v1-shaped paths
|
|
10
|
+
* through `seamPath()`) a SCIM-provisioned user and a SAML assertion carrying
|
|
11
|
+
* the same persistent NameID form a link; the record MUST validate against
|
|
12
|
+
* `subject-link.schema.json` (closed; two SubjectRefs; never legacy or
|
|
13
|
+
* anonymous), and deactivation MUST set `deniedAt` and deny the SAML path. The
|
|
14
|
+
* record is read from the seam responses (`link`) or from
|
|
15
|
+
* `GET …/auth/subject-links?externalId=`; a host that surfaces neither records
|
|
16
|
+
* `blocked`. Opt-in: `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
21
|
+
import { v2Discovery, v2Validator, familyAdvertised } from '../lib/v2.js';
|
|
22
|
+
import { seamPath, seamsProfileAdvertised } from '../lib/seams.js';
|
|
23
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
|
|
26
|
+
const DOC = 'spec/v2/core/identity.md §3';
|
|
27
|
+
const PROVISION = seamPath('/v1/host/sample/auth/scim/provision');
|
|
28
|
+
const VALIDATE = seamPath('/v1/host/sample/auth/saml/validate');
|
|
29
|
+
const LINKS = seamPath('/v1/host/sample/auth/subject-links');
|
|
30
|
+
|
|
31
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
32
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
35
|
+
try { return await fn(); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Formed = { readonly link: Record<string, unknown>; readonly externalId: string; readonly idpUrl: string; readonly scimUrl: string } | { readonly kind: 'blocked' | 'inapplicable' | 'skipped' | 'absent'; readonly reason: string };
|
|
39
|
+
|
|
40
|
+
function linkOf(json: unknown): Record<string, unknown> | null {
|
|
41
|
+
const l = (json as { link?: unknown } | undefined)?.link;
|
|
42
|
+
return l !== null && typeof l === 'object' && !Array.isArray(l) ? (l as Record<string, unknown>) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readLink(externalId: string): Promise<Record<string, unknown> | null> {
|
|
46
|
+
const res = await http(() => driver.get(`${LINKS}?externalId=${encodeURIComponent(externalId)}`));
|
|
47
|
+
if (res === null || res.status !== 200) return null;
|
|
48
|
+
const body = res.json as { link?: unknown; links?: unknown } | undefined;
|
|
49
|
+
const direct = linkOf(body);
|
|
50
|
+
if (direct !== null) return direct;
|
|
51
|
+
const arr = body?.links;
|
|
52
|
+
return Array.isArray(arr) && arr.length > 0 && arr[0] !== null && typeof arr[0] === 'object' ? (arr[0] as Record<string, unknown>) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Provision + assert; returns the link record the seams surfaced, or a reason. */
|
|
56
|
+
async function form(): Promise<Formed> {
|
|
57
|
+
const doc = await discovery();
|
|
58
|
+
if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
59
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'blocked', reason: 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — the link record is seam-gated' };
|
|
60
|
+
const auth = await familyAdvertised('auth');
|
|
61
|
+
const lanes = new Set((Array.isArray(auth?.['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : []).map((l) => String(l['lane'])));
|
|
62
|
+
if (!(lanes.has('saml') && lanes.has('scim'))) return { kind: 'inapplicable', reason: 'the host does not advertise both the saml and scim lanes — advertising both is what implies the linking contract (row C2.5)' };
|
|
63
|
+
const idpUrl = process.env['OPENWOP_TEST_SAML_IDP_URL'];
|
|
64
|
+
const scimUrl = process.env['OPENWOP_TEST_SCIM_URL'];
|
|
65
|
+
if (!idpUrl || !scimUrl) return { kind: 'skipped', reason: 'opt-in not supplied: OPENWOP_TEST_SAML_IDP_URL and OPENWOP_TEST_SCIM_URL are required to drive the SCIM/SAML seams' };
|
|
66
|
+
|
|
67
|
+
const externalId = `idp-op-${Date.now().toString(36)}`;
|
|
68
|
+
const provision = await http(() => driver.post(PROVISION, { scimUrl, op: 'create-user', externalId, userName: 'r.smith' }));
|
|
69
|
+
if (provision === null) return { kind: 'blocked', reason: `${PROVISION} unreachable (fetch failed)` };
|
|
70
|
+
if (provision.status === 404 || provision.status === 403) return { kind: 'absent', reason: `${PROVISION} not mounted (${provision.status})` };
|
|
71
|
+
const assertion = await http(() => driver.post(VALIDATE, { idpUrl, variant: 'valid', nameId: externalId }));
|
|
72
|
+
if (assertion === null) return { kind: 'blocked', reason: `${VALIDATE} unreachable (fetch failed)` };
|
|
73
|
+
if (assertion.status === 404 || assertion.status === 403) return { kind: 'absent', reason: `${VALIDATE} not mounted (${assertion.status})` };
|
|
74
|
+
const link = linkOf(assertion.json) ?? linkOf(provision.json) ?? (await readLink(externalId));
|
|
75
|
+
if (link === null) return { kind: 'absent', reason: `neither seam response carries \`link\` and ${LINKS}?externalId= is not served — the SubjectLink record RFC 0170 §C.1 defines is not surfaced by this host` };
|
|
76
|
+
return { link, externalId, idpUrl, scimUrl };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('v2 subject-link-record (RFC 0170 §C.1 — seam-gated)', () => {
|
|
80
|
+
it('the link formed across SCIM and SAML is a closed SubjectLink record', async () => {
|
|
81
|
+
const f = await form();
|
|
82
|
+
if ('kind' in f) return f.kind === 'absent' ? seamAbsent(f.reason) : softSkip(f.kind, f.reason);
|
|
83
|
+
const r = v2Validator('subject-link')(f.link);
|
|
84
|
+
expect(r.ok, req('openwop.requirement.0170.subject-link-record.shape', DOC, `the link MUST validate against schemas/v2/subject-link.schema.json — { a, b, keyClass, issuer, tenant, formedAt, deniedAt? }, closed, never a legacy or anonymous subject (${r.errors})`)).toBe(true);
|
|
85
|
+
const a = f.link['a'] as { issuer?: unknown; subjectId?: unknown };
|
|
86
|
+
const b = f.link['b'] as { issuer?: unknown; subjectId?: unknown };
|
|
87
|
+
expect(`${String(a.issuer)}/${String(a.subjectId)}` !== `${String(b.issuer)}/${String(b.subjectId)}`, req('openwop.requirement.0170.subject-link-record.shape', DOC, 'a link MUST join exactly two distinct subjects')).toBe(true);
|
|
88
|
+
expect(f.link['deniedAt'], req('openwop.requirement.0170.subject-link-record.shape', DOC, 'a freshly formed link carries no deniedAt')).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('deactivation sets deniedAt and the SAML decision path consults it', async () => {
|
|
92
|
+
const f = await form();
|
|
93
|
+
if ('kind' in f) return f.kind === 'absent' ? seamAbsent(f.reason) : softSkip(f.kind, f.reason);
|
|
94
|
+
const deactivate = await http(() => driver.post(PROVISION, { scimUrl: f.scimUrl, op: 'deactivate-user', externalId: f.externalId }));
|
|
95
|
+
expect(deactivate !== null && deactivate.status < 400, req('openwop.requirement.0170.subject-link-record.denied-at', DOC, `SCIM deactivation MUST succeed (got ${deactivate?.status ?? 'no response'})`)).toBe(true);
|
|
96
|
+
const after = await http(() => driver.post(VALIDATE, { idpUrl: f.idpUrl, variant: 'valid', nameId: f.externalId }));
|
|
97
|
+
const denied = linkOf(deactivate?.json) ?? linkOf(after?.json) ?? (await readLink(f.externalId));
|
|
98
|
+
if (denied === null) return seamAbsent('the deactivated link record is not surfaced by the seams — deniedAt cannot be read');
|
|
99
|
+
expect(typeof denied['deniedAt'] === 'string' && !Number.isNaN(Date.parse(denied['deniedAt'] as string)), req('openwop.requirement.0170.subject-link-record.denied-at', DOC, 'deactivation MUST set deniedAt (date-time) on the link record')).toBe(true);
|
|
100
|
+
expect((after?.json as { authenticated?: unknown } | undefined)?.authenticated === true, req('openwop.requirement.0170.subject-link-record.denied-at', DOC, 'after deniedAt is set the SAML decision path MUST deny the linked subject (the leaver contract)')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A.3 — `unmapped-type-refused` (suite 2.0.0, target major 2;
|
|
3
|
+
* seam-gated on `openwop-conformance-seams-v2`).
|
|
4
|
+
*
|
|
5
|
+
* A type the codemap does not name and that carries no reserved vendor prefix
|
|
6
|
+
* MUST fail the read with `event_type_unmapped` (`spec/v2/errors.json`, `500`,
|
|
7
|
+
* not retriable) — a run whose log the host cannot translate is not readable,
|
|
8
|
+
* not "tolerantly" readable (`spec/v2/core/persistence.md` §The reader rule;
|
|
9
|
+
* migration row C9.3). The v1 tolerant reader (unknown type passed through) is
|
|
10
|
+
* the forbidden path.
|
|
11
|
+
*
|
|
12
|
+
* An era-2 log carrying one `foo.bar` row is seeded through the event-log seed
|
|
13
|
+
* seam (lib/era2-seed.ts); the read is driven through poll and, where `replay`
|
|
14
|
+
* is advertised, through a fork. A vendor-prefixed control (read under its own
|
|
15
|
+
* name) needs an org registered under `extensions` in `spec/v2/declaration.json`
|
|
16
|
+
* that the suite does not own, so that half is not driven.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/persistence.md §The reader rule
|
|
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 { readErrorCode, readRetriable } from '../lib/error-envelope.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { req } from '../lib/requirement-ids.js';
|
|
27
|
+
import { era2Gate, eventsOf, forkRun, pollEvents, seedEra2Log, type SeedEvent } from '../lib/era2-seed.js';
|
|
28
|
+
|
|
29
|
+
const DOC = 'spec/v2/core/persistence.md §The reader rule';
|
|
30
|
+
const CODE = 'event_type_unmapped';
|
|
31
|
+
const UNMAPPED = 'foo.bar';
|
|
32
|
+
|
|
33
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
34
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unmappedLog(): SeedEvent[] {
|
|
38
|
+
const t0 = Date.parse('2026-01-15T11:00:00.000Z');
|
|
39
|
+
const ts = (i: number) => new Date(t0 + i * 1000).toISOString();
|
|
40
|
+
return [
|
|
41
|
+
{ type: 'run.started', sequence: 0, payload: { workflowId: 'conformance-noop' }, timestamp: ts(0) },
|
|
42
|
+
{ type: UNMAPPED, sequence: 1, payload: { note: 'not a codemap row, not a vendor org' }, timestamp: ts(1) },
|
|
43
|
+
{ type: 'run.completed', sequence: 2, payload: { durationMs: 2000 }, timestamp: ts(2) },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('RFC 0176 §A.3 — unmapped-type-refused (seam-gated)', () => {
|
|
48
|
+
it('poll over a log with an unmapped, unprefixed type fails with 500 event_type_unmapped', async () => {
|
|
49
|
+
const doc = await discovery();
|
|
50
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
51
|
+
const gate = era2Gate(doc);
|
|
52
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
53
|
+
const seeded = await seedEra2Log(unmappedLog(), 'completed');
|
|
54
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
55
|
+
const res = await pollEvents(seeded.runId);
|
|
56
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll unreachable (fetch failed)');
|
|
57
|
+
expect(
|
|
58
|
+
res.status,
|
|
59
|
+
req('openwop.requirement.0176.unmapped-type-refused', DOC, `a read over a log the host cannot translate MUST fail — ${CODE} is registered 500 (spec/v2/errors.json); got ${res.status}${res.status === 200 ? ` with types [${eventsOf(res.json).map((e) => String(e.type)).join(', ')}] — the tolerant reader RFC 0176 forbids` : ''}`),
|
|
60
|
+
).toBe(500);
|
|
61
|
+
expect(
|
|
62
|
+
readErrorCode(res.json),
|
|
63
|
+
req('openwop.requirement.0176.unmapped-type-refused', DOC, `the refusal MUST name ${CODE} in the canonical envelope`),
|
|
64
|
+
).toBe(CODE);
|
|
65
|
+
expect(
|
|
66
|
+
readRetriable(res.json) === true,
|
|
67
|
+
req('openwop.requirement.0176.unmapped-type-refused', 'spec/v2/errors.json', `${CODE} is not retriable — the log does not become translatable by asking again`),
|
|
68
|
+
).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('a fork of the same log is refused for the same reason — the rule binds every reader', async () => {
|
|
72
|
+
const doc = await discovery();
|
|
73
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
74
|
+
const gate = era2Gate(doc);
|
|
75
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
76
|
+
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — the fork reader has no surface');
|
|
77
|
+
const log = unmappedLog();
|
|
78
|
+
const seeded = await seedEra2Log(log, 'completed');
|
|
79
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
80
|
+
const last = Math.max(...log.map((e) => e.sequence));
|
|
81
|
+
const fork = await forkRun(seeded.runId, { mode: 'replay', fromSeq: last });
|
|
82
|
+
if (fork === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
83
|
+
// The fork loads the source prefix through the storage boundary (replay.md
|
|
84
|
+
// §Replay-from-event-log internals step 1) — the unmapped row at sequence 1
|
|
85
|
+
// is inside it, so the fork MUST fail the read rather than copy the row.
|
|
86
|
+
expect(
|
|
87
|
+
fork.status,
|
|
88
|
+
req('openwop.requirement.0176.unmapped-type-refused.fork', 'spec/v2/core/replay.md §Replay-from-event-log internals', `a fork whose inherited prefix holds an unmapped type MUST fail the read with ${CODE} (500) — got ${fork.status}; a 201 copied a row the host cannot translate into a new log`),
|
|
89
|
+
).toBe(500);
|
|
90
|
+
expect(
|
|
91
|
+
readErrorCode(fork.json),
|
|
92
|
+
req('openwop.requirement.0176.unmapped-type-refused.fork', DOC, `the fork's refusal MUST name ${CODE}`),
|
|
93
|
+
).toBe(CODE);
|
|
94
|
+
});
|
|
95
|
+
});
|