@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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `payload-registry-closed` (suite 2.0.0; RFC 0171 §A.4;
|
|
3
|
+
* `spec/v2/core/events.md` §"Payloads").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `schemas/v2/run-event-payloads.schema.json`
|
|
6
|
+
* holds one closed `$defs` entry per payload and the NORMATIVE `_typeIndex`
|
|
7
|
+
* from v2 type to `$defs` key. A host MUST emit, for every registered type, a
|
|
8
|
+
* payload that validates against the entry `_typeIndex` names; a registered
|
|
9
|
+
* type with no index entry is a corpus defect and fails here too. Vendor
|
|
10
|
+
* events have no registry entry and are not checked. The payload validator is
|
|
11
|
+
* built locally (a `$ref` into the payload registry's `$defs`), because
|
|
12
|
+
* `v2Validator` resolves whole schema files only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
19
|
+
import addFormats from 'ajv-formats';
|
|
20
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
21
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
22
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
23
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
|
|
27
|
+
const DOC = 'spec/v2/core/events.md §Payloads';
|
|
28
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
29
|
+
const PAYLOADS_ID = 'https://openwop.dev/spec/v2/run-event-payloads.schema.json';
|
|
30
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
31
|
+
const TIMEOUT_MS = Number(process.env['OPENWOP_LIFECYCLE_TIMEOUT_MS'] ?? 10_000);
|
|
32
|
+
|
|
33
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
34
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
37
|
+
try { return await fn(); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let ajv: Ajv2020 | undefined;
|
|
41
|
+
function registry(): Ajv2020 {
|
|
42
|
+
if (ajv) return ajv;
|
|
43
|
+
ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
44
|
+
addFormats(ajv);
|
|
45
|
+
const walk = (d: string): string[] => readdirSync(d).flatMap((n) => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : p.endsWith('.schema.json') ? [p] : []; });
|
|
46
|
+
for (const p of walk(join(SCHEMAS_DIR, 'v2'))) { try { ajv.addSchema(JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>); } catch { /* a duplicate $id is registered once */ } }
|
|
47
|
+
return ajv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `_typeIndex.properties[type].$ref` → the `$defs` key. */
|
|
51
|
+
function typeIndex(): Map<string, string> {
|
|
52
|
+
const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event-payloads.schema.json'), 'utf8')) as { _typeIndex?: { properties?: Record<string, { $ref?: string }> } };
|
|
53
|
+
const out = new Map<string, string>();
|
|
54
|
+
for (const [type, ref] of Object.entries(schema._typeIndex?.properties ?? {})) {
|
|
55
|
+
const key = ref.$ref?.replace(/^#\/\$defs\//, '');
|
|
56
|
+
if (key !== undefined) out.set(type, key);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function protocolTypes(): Set<string> {
|
|
62
|
+
const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event.schema.json'), 'utf8')) as { properties: { type: { oneOf: Array<{ enum?: string[] }> } } };
|
|
63
|
+
return new Set(schema.properties.type.oneOf.flatMap((b) => b.enum ?? []));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const defValidators = new Map<string, ReturnType<Ajv2020['compile']>>();
|
|
67
|
+
function validatePayload(defKey: string, payload: unknown): { ok: boolean; errors: string } {
|
|
68
|
+
const a = registry();
|
|
69
|
+
let v = defValidators.get(defKey);
|
|
70
|
+
if (v === undefined) { v = a.compile({ $ref: `${PAYLOADS_ID}#/$defs/${defKey}` }); defValidators.set(defKey, v); }
|
|
71
|
+
return { ok: v(payload) as boolean, errors: a.errorsText(v.errors, { separator: '; ' }) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function terminalEvents(): Promise<unknown[] | { reason: string }> {
|
|
75
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
76
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
77
|
+
if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
78
|
+
const runId = (created.json as { runId?: unknown } | undefined)?.runId;
|
|
79
|
+
if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
80
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
83
|
+
if (snap !== null && TERMINAL.has(String((snap.json as { status?: unknown } | undefined)?.status))) break;
|
|
84
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
85
|
+
}
|
|
86
|
+
const poll = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
87
|
+
if (poll === null || poll.status !== 200) return { reason: `GET /runs/{runId}/events/poll answered ${poll?.status ?? 'no response'}` };
|
|
88
|
+
const events = (poll.json as { events?: unknown } | undefined)?.events;
|
|
89
|
+
if (!Array.isArray(events) || events.length === 0) return { reason: 'the poll returned no events for a run driven to terminal status — no payload is observable' };
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('v2 payload-registry-closed (RFC 0171 §A.4)', () => {
|
|
94
|
+
it('every registered event carries a payload that validates against its _typeIndex entry', async () => {
|
|
95
|
+
const events = await terminalEvents();
|
|
96
|
+
if (!Array.isArray(events)) return softSkip('blocked', events.reason);
|
|
97
|
+
const index = typeIndex();
|
|
98
|
+
const enumSet = protocolTypes();
|
|
99
|
+
const registered = events.filter((ev) => enumSet.has(String((ev as { type?: unknown }).type)));
|
|
100
|
+
if (registered.length === 0) return softSkip('blocked', 'the run emitted only vendor events — no registered payload to check');
|
|
101
|
+
for (const ev of registered) {
|
|
102
|
+
const type = String((ev as { type?: unknown }).type);
|
|
103
|
+
const key = index.get(type);
|
|
104
|
+
expect(key, req('openwop.requirement.0171.payload-registry-closed', DOC, `_typeIndex is normative: registered type ${type} MUST map to a $defs entry in run-event-payloads.schema.json`)).toBeDefined();
|
|
105
|
+
if (key === undefined) continue;
|
|
106
|
+
const r = validatePayload(key, (ev as { payload?: unknown }).payload);
|
|
107
|
+
expect(r.ok, req('openwop.requirement.0171.payload-registry-closed', DOC, `the payload of ${type} MUST validate against $defs/${key} (every entry additionalProperties: false) (${r.errors})`)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-peer-dependency-declared — RFC 0177 §B.1/§B.2; `spec/v2/core/packs.md`
|
|
3
|
+
* §"Peer-dependency identifiers" and §"The alias table".
|
|
4
|
+
*
|
|
5
|
+
* Suite 2.0.0, target major 2. A `peerDependencies` key MUST be a root key of
|
|
6
|
+
* `spec/v2/declaration.json`; a host MUST refuse a key the declaration file does
|
|
7
|
+
* not name with `pack_peer_dependency_undefined` (400). During the overlap a
|
|
8
|
+
* host MAY resolve a v1 grammar through the generated alias table
|
|
9
|
+
* (`spec/v2/peer-dependency-aliases.json`, e.g. `host.fs → fs`).
|
|
10
|
+
*
|
|
11
|
+
* 1. `peerDependencies: { "host.nonexistent": "required" }` → refused,
|
|
12
|
+
* pack_peer_dependency_undefined.
|
|
13
|
+
* 2. an alias row whose family the host advertises (`host.fs` when `fs` is
|
|
14
|
+
* advertised, else the first facet-less row with an advertised family)
|
|
15
|
+
* installs through the overlap. §B.2 is a MAY, so a host that refuses the
|
|
16
|
+
* alias with pack_peer_dependency_undefined records `skipped`, not a
|
|
17
|
+
* failure; any other refusal fails.
|
|
18
|
+
*
|
|
19
|
+
* Both legs install through the seams-profile publish seam
|
|
20
|
+
* (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on `packs` + the
|
|
21
|
+
* seams profile.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §B.1, §B.2
|
|
24
|
+
* @see spec/v2/core/packs.md §"Peer-dependency identifiers", §"The alias table"
|
|
25
|
+
* @see spec/v2/peer-dependency-aliases.json
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { gzipSync } from 'node:zlib';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
37
|
+
import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
|
|
38
|
+
import { v2Discovery, gateFamily, familyAdvertised } from '../lib/v2.js';
|
|
39
|
+
|
|
40
|
+
const SECTION = 'packs.md §"Peer-dependency identifiers" (RFC 0177 §B.1)';
|
|
41
|
+
const ALIASES = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'peer-dependency-aliases.json');
|
|
42
|
+
|
|
43
|
+
interface AliasRow { alias: string; family: string; facets?: string[] }
|
|
44
|
+
|
|
45
|
+
function tarEntry(name: string, data: Buffer): Buffer {
|
|
46
|
+
const h = Buffer.alloc(512, 0);
|
|
47
|
+
h.write(name, 0, 100, 'utf8');
|
|
48
|
+
h.write('0000644\0', 100, 8, 'utf8');
|
|
49
|
+
h.write('0000000\0', 108, 8, 'utf8');
|
|
50
|
+
h.write('0000000\0', 116, 8, 'utf8');
|
|
51
|
+
h.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf8');
|
|
52
|
+
h.write('00000000000\0', 136, 12, 'utf8');
|
|
53
|
+
h.write(' ', 148, 8, 'utf8');
|
|
54
|
+
h.write('0', 156, 1, 'utf8');
|
|
55
|
+
h.write('ustar\0', 257, 6, 'utf8');
|
|
56
|
+
h.write('00', 263, 2, 'utf8');
|
|
57
|
+
let sum = 0;
|
|
58
|
+
for (const b of h) sum += b;
|
|
59
|
+
h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8');
|
|
60
|
+
const pad = (512 - (data.length % 512)) % 512;
|
|
61
|
+
return Buffer.concat([h, data, Buffer.alloc(pad, 0)]);
|
|
62
|
+
}
|
|
63
|
+
function packTarball(files: Record<string, string>): Buffer {
|
|
64
|
+
return gzipSync(Buffer.concat([...Object.entries(files).map(([n, c]) => tarEntry(n, Buffer.from(c, 'utf8'))), Buffer.alloc(1024, 0)]));
|
|
65
|
+
}
|
|
66
|
+
const ENTRY = 'export default { execute: async (input) => input };\n';
|
|
67
|
+
function freshName(slug: string): string {
|
|
68
|
+
return `core.openwop.v2-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
69
|
+
}
|
|
70
|
+
function nodePack(name: string, peerDependencies: Record<string, string>): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
version: '1.0.0',
|
|
74
|
+
kind: 'node',
|
|
75
|
+
engines: { openwop: '>=2.0.0 <3.0.0' },
|
|
76
|
+
runtime: { language: 'javascript', entry: 'index.mjs', format: 'esm' },
|
|
77
|
+
peerDependencies,
|
|
78
|
+
nodes: [{ typeId: `${name}.echo`, version: '1.0.0', category: 'data', role: 'pure' }],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function publish(manifest: Record<string, unknown>) {
|
|
82
|
+
const name = manifest['name'] as string;
|
|
83
|
+
const version = manifest['version'] as string;
|
|
84
|
+
const body = packTarball({ 'pack.json': JSON.stringify(manifest, null, 2), 'index.mjs': ENTRY });
|
|
85
|
+
return driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, body, {
|
|
86
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
90
|
+
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
91
|
+
let doc: Record<string, unknown> | null;
|
|
92
|
+
try {
|
|
93
|
+
doc = await v2Discovery();
|
|
94
|
+
} catch {
|
|
95
|
+
doc = null;
|
|
96
|
+
}
|
|
97
|
+
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
98
|
+
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
99
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'blocked', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('v2-peer-dependency-declared (RFC 0177 §B.1/§B.2)', () => {
|
|
104
|
+
it('a peerDependencies key the declaration file does not name is refused with pack_peer_dependency_undefined', async () => {
|
|
105
|
+
const skip = await preflight();
|
|
106
|
+
if (skip) return softSkip(skip.kind, skip.reason);
|
|
107
|
+
const res = await publish(nodePack(freshName('peer-undefined'), { 'host.nonexistent': 'required' }));
|
|
108
|
+
if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
|
|
109
|
+
expect(res.status, req('openwop.requirement.0177.peer-dependency-declared.undefined-key', SECTION, 'a key absent from spec/v2/declaration.json MUST be refused with 400 pack_peer_dependency_undefined')).toBe(400);
|
|
110
|
+
expect(readErrorCode(res.json), req('openwop.requirement.0177.peer-dependency-declared.undefined-key', SECTION, 'the refusal code MUST be pack_peer_dependency_undefined')).toBe('pack_peer_dependency_undefined');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('an alias-table row (host.fs → fs) resolves through the overlap and the pack installs', async () => {
|
|
114
|
+
const skip = await preflight();
|
|
115
|
+
if (skip) return softSkip(skip.kind, skip.reason);
|
|
116
|
+
if (!existsSync(ALIASES)) return softSkip('blocked', 'spec/v2/peer-dependency-aliases.json is not present in this layout — the alias row cannot be chosen');
|
|
117
|
+
const rows = (JSON.parse(readFileSync(ALIASES, 'utf8')) as { rows: AliasRow[] }).rows.filter((r) => !r.facets || r.facets.length === 0);
|
|
118
|
+
let chosen: AliasRow | null = null;
|
|
119
|
+
const fs = rows.find((r) => r.alias === 'host.fs');
|
|
120
|
+
if (fs && (await familyAdvertised(fs.family))) chosen = fs;
|
|
121
|
+
for (const r of rows) {
|
|
122
|
+
if (chosen) break;
|
|
123
|
+
if (await familyAdvertised(r.family)) chosen = r;
|
|
124
|
+
}
|
|
125
|
+
if (!chosen) return softSkip('inapplicable', 'no facet-less alias row names a family this host advertises — the overlap alias cannot be exercised');
|
|
126
|
+
const res = await publish(nodePack(freshName('peer-alias'), { [chosen.alias]: 'required' }));
|
|
127
|
+
if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
|
|
128
|
+
const code = readErrorCode(res.json);
|
|
129
|
+
if (res.status === 400 && code === 'pack_peer_dependency_undefined') return softSkip('skipped', `host does not resolve the overlap alias ${chosen.alias} (RFC 0177 §B.2 is a MAY during the overlap; MUST NOT after v1 end-of-support)`);
|
|
130
|
+
expect([200, 201].includes(res.status), req('openwop.requirement.0177.peer-dependency-declared.alias-overlap', 'packs.md §"The alias table" (RFC 0177 §B.2)', `alias ${chosen.alias} → ${chosen.family} (advertised) MUST install through the overlap or be refused only with pack_peer_dependency_undefined (got ${res.status} ${code ?? ''})`)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §B.1 — `pinned-run-disposition` (suite 2.0.0, target major 2;
|
|
3
|
+
* seam-gated on `openwop-conformance-seams-v2`).
|
|
4
|
+
*
|
|
5
|
+
* A non-terminal run a v2 host inherits carries `version.pinned` events naming
|
|
6
|
+
* change ids. The host MUST continue it or cancel it, never follow a pin
|
|
7
|
+
* silently: every pinned change id still implemented ⇒ the run continues under
|
|
8
|
+
* the reader rule with the pin honoured verbatim (`version.pinned` is never
|
|
9
|
+
* rewritten); any pinned change id no longer implemented ⇒ `run.cancelled`
|
|
10
|
+
* with reason `v1_pin_unsupported` and `cancelledBy: "v2-cutover"`
|
|
11
|
+
* (`spec/v2/core/persistence.md` §Runs pinned to v1; migration rows C9.4/C9.5).
|
|
12
|
+
*
|
|
13
|
+
* Legs:
|
|
14
|
+
* 1. an era-2 running log seeded through the event-log seed seam
|
|
15
|
+
* (lib/era2-seed.ts) whose `version.pinned` names a change id no host
|
|
16
|
+
* implements reads back `cancelled` with the named reason and actor, and
|
|
17
|
+
* the pin row survives untouched;
|
|
18
|
+
* 2. a pin naming a change id the host implements continues — no normative
|
|
19
|
+
* surface lists implemented change ids, so the id is the operator opt-in
|
|
20
|
+
* `OPENWOP_TEST_IMPLEMENTED_CHANGE_ID`; absent, the leg records `skipped`.
|
|
21
|
+
*
|
|
22
|
+
* @see spec/v2/core/persistence.md §Runs pinned to v1
|
|
23
|
+
* @see schemas/v2/run-event-payloads.schema.json runCancelled
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
28
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
29
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
import { era2Gate, eventsOf, pollEvents, seedEra2Log, type ReadEvent, type SeedEvent } from '../lib/era2-seed.js';
|
|
32
|
+
|
|
33
|
+
const DOC = 'spec/v2/core/persistence.md §Runs pinned to v1';
|
|
34
|
+
const REASON = 'v1_pin_unsupported';
|
|
35
|
+
const CANCELLED_BY = 'v2-cutover';
|
|
36
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
37
|
+
const SETTLE_MS = Number(process.env['OPENWOP_LIFECYCLE_TIMEOUT_MS'] ?? 10_000);
|
|
38
|
+
|
|
39
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
40
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
43
|
+
try { return await fn(); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pinnedLog(changeId: string): SeedEvent[] {
|
|
47
|
+
const t0 = Date.parse('2026-01-15T12:00:00.000Z');
|
|
48
|
+
const ts = (i: number) => new Date(t0 + i * 1000).toISOString();
|
|
49
|
+
return [
|
|
50
|
+
{ type: 'run.started', sequence: 0, payload: { workflowId: 'conformance-noop' }, timestamp: ts(0) },
|
|
51
|
+
{ type: 'version.pinned', sequence: 1, payload: { changeId, version: 1 }, timestamp: ts(1) },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Read = { readonly ok: true; readonly status: string; readonly events: ReadEvent[] } | { readonly ok: false; readonly kind: 'blocked' | 'inapplicable' | 'skipped'; readonly reason: string };
|
|
56
|
+
|
|
57
|
+
/** Seed, then read the snapshot status and the log once the host has decided (the disposition is applied at first v2 read or at the cut). */
|
|
58
|
+
async function seedAndRead(changeId: string): Promise<Read> {
|
|
59
|
+
const seeded = await seedEra2Log(pinnedLog(changeId), 'running');
|
|
60
|
+
if (!seeded.ok) return { ok: false, kind: seeded.kind, reason: seeded.reason };
|
|
61
|
+
const deadline = Date.now() + SETTLE_MS;
|
|
62
|
+
let status = '';
|
|
63
|
+
for (;;) {
|
|
64
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(seeded.runId)}`));
|
|
65
|
+
if (snap === null || snap.status !== 200) return { ok: false, kind: 'blocked', reason: `GET /runs/{runId} answered ${snap?.status ?? 'no response'} for the seeded run` };
|
|
66
|
+
status = String((snap.json as { status?: unknown } | undefined)?.status ?? '');
|
|
67
|
+
if (TERMINAL.has(status) || Date.now() > deadline) break;
|
|
68
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
69
|
+
}
|
|
70
|
+
const poll = await pollEvents(seeded.runId);
|
|
71
|
+
if (poll === null || poll.status !== 200) return { ok: false, kind: 'blocked', reason: `GET /runs/{runId}/events/poll answered ${poll?.status ?? 'no response'} for the seeded run` };
|
|
72
|
+
return { ok: true, status, events: eventsOf(poll.json) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('RFC 0176 §B.1 — pinned-run-disposition (seam-gated)', () => {
|
|
76
|
+
it('a pin naming a change id the host does not implement is cancelled with v1_pin_unsupported / v2-cutover', async () => {
|
|
77
|
+
const doc = await discovery();
|
|
78
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
79
|
+
const gate = era2Gate(doc);
|
|
80
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
81
|
+
const changeId = `conformance-unknown-change-${Date.now().toString(36)}`;
|
|
82
|
+
const r = await seedAndRead(changeId);
|
|
83
|
+
if (!r.ok) return softSkip(r.kind, r.reason);
|
|
84
|
+
expect(
|
|
85
|
+
r.status,
|
|
86
|
+
req('openwop.requirement.0176.pinned-run-disposition.cancelled', DOC, `a run pinned to a change id the host no longer implements MUST be cancelled, never followed silently — snapshot status is ${r.status}`),
|
|
87
|
+
).toBe('cancelled');
|
|
88
|
+
const cancelled = r.events.filter((e) => e.type === 'run.cancelled');
|
|
89
|
+
expect(cancelled.length, req('openwop.requirement.0176.pinned-run-disposition.cancelled', DOC, 'the cancellation MUST be an event on the run\'s own log (run.cancelled), not a silent status flip')).toBe(1);
|
|
90
|
+
const payload = (cancelled[0]?.payload ?? {}) as { reason?: unknown; cancelledBy?: unknown };
|
|
91
|
+
expect(payload.reason, req('openwop.requirement.0176.pinned-run-disposition.cancelled', 'run-event-payloads.schema.json runCancelled.reason', `reason MUST be the registered ${REASON}`)).toBe(REASON);
|
|
92
|
+
expect(payload.cancelledBy, req('openwop.requirement.0176.pinned-run-disposition.cancelled', 'run-event-payloads.schema.json runCancelled.cancelledBy', `cancelledBy MUST be ${CANCELLED_BY}`)).toBe(CANCELLED_BY);
|
|
93
|
+
const pin = r.events.find((e) => e.type === 'version.pinned');
|
|
94
|
+
expect(pin, req('openwop.requirement.0176.pinned-run-disposition.cancelled', DOC, 'the version.pinned row MUST survive the cancellation — the log is preserved (adversarial review 4)')).toBeDefined();
|
|
95
|
+
expect(
|
|
96
|
+
(pin?.payload as { changeId?: unknown } | undefined)?.changeId,
|
|
97
|
+
req('openwop.requirement.0176.pinned-run-disposition.cancelled', DOC, 'version.pinned is never rewritten: the pin still names the change id it was seeded with'),
|
|
98
|
+
).toBe(changeId);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a pin naming a change id the host still implements continues under the reader rule with the pin verbatim', async () => {
|
|
102
|
+
const doc = await discovery();
|
|
103
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
104
|
+
const gate = era2Gate(doc);
|
|
105
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
106
|
+
const changeId = process.env['OPENWOP_TEST_IMPLEMENTED_CHANGE_ID']?.trim();
|
|
107
|
+
if (!changeId) return softSkip('skipped', 'opt-in not supplied: no normative surface lists the change ids a host implements, so the continue leg needs OPENWOP_TEST_IMPLEMENTED_CHANGE_ID naming one');
|
|
108
|
+
const r = await seedAndRead(changeId);
|
|
109
|
+
if (!r.ok) return softSkip(r.kind, r.reason);
|
|
110
|
+
expect(
|
|
111
|
+
r.status,
|
|
112
|
+
req('openwop.requirement.0176.pinned-run-disposition.continued', DOC, `a run whose every pinned change id is still implemented MUST continue under the adapter — it was cancelled (status ${r.status}) for a pin the operator says the host implements (${changeId})`),
|
|
113
|
+
).not.toBe('cancelled');
|
|
114
|
+
expect(
|
|
115
|
+
r.events.filter((e) => e.type === 'run.cancelled' && (e.payload as { reason?: unknown } | undefined)?.reason === REASON),
|
|
116
|
+
req('openwop.requirement.0176.pinned-run-disposition.continued', DOC, `no run.cancelled { reason: ${REASON} } may appear on a run whose pin is implemented`),
|
|
117
|
+
).toEqual([]);
|
|
118
|
+
const pin = r.events.find((e) => e.type === 'version.pinned');
|
|
119
|
+
expect(
|
|
120
|
+
(pin?.payload as { changeId?: unknown; version?: unknown } | undefined),
|
|
121
|
+
req('openwop.requirement.0176.pinned-run-disposition.continued', DOC, 'the pin is honoured verbatim — version.pinned reads back with the seeded changeId and version'),
|
|
122
|
+
).toMatchObject({ changeId, version: 1 });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `poll-cursor-v2` (suite 2.0.0; RFC 0171 §E.2;
|
|
3
|
+
* `spec/v2/core/events.md` §"Poll").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `GET /runs/{runId}/events/poll`
|
|
6
|
+
* without a cursor returns from the first event (sequence 0);
|
|
7
|
+
* `?afterSequence=N` returns only events with `sequence > N`; a cursor past
|
|
8
|
+
* the end of the log returns `200` with an empty `events` array; the response
|
|
9
|
+
* is the closed `{ runId, events, lastSequence, status, isTerminal }`. The run
|
|
10
|
+
* is the `conformance-noop` fixture, awaited to a terminal status.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
15
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
16
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
17
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
18
|
+
import { req } from '../lib/requirement-ids.js';
|
|
19
|
+
|
|
20
|
+
const DOC = 'spec/v2/core/events.md §Poll';
|
|
21
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
22
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
23
|
+
const TIMEOUT_MS = Number(process.env['OPENWOP_LIFECYCLE_TIMEOUT_MS'] ?? 10_000);
|
|
24
|
+
const SHAPE = ['runId', 'events', 'lastSequence', 'status', 'isTerminal'];
|
|
25
|
+
|
|
26
|
+
interface Poll { readonly runId?: unknown; readonly events?: unknown; readonly lastSequence?: unknown; readonly status?: unknown; readonly isTerminal?: unknown }
|
|
27
|
+
|
|
28
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
29
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
32
|
+
try { return await fn(); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function terminalRun(): Promise<{ runId: string } | { reason: string }> {
|
|
36
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
37
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
38
|
+
if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
39
|
+
const runId = (created.json as { runId?: unknown } | undefined)?.runId;
|
|
40
|
+
if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
41
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
44
|
+
if (snap !== null && TERMINAL.has(String((snap.json as { status?: unknown } | undefined)?.status))) break;
|
|
45
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
46
|
+
}
|
|
47
|
+
return { runId };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function poll(runId: string, query: string): Promise<OpenWOPResponse | null> {
|
|
51
|
+
return http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1${query}`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sequences(body: unknown): number[] {
|
|
55
|
+
const events = (body as Poll | undefined)?.events;
|
|
56
|
+
return Array.isArray(events) ? events.map((e) => Number((e as { sequence?: unknown }).sequence)) : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('v2 poll-cursor-v2 (RFC 0171 §E.2)', () => {
|
|
60
|
+
it('the response is the closed { runId, events, lastSequence, status, isTerminal }', async () => {
|
|
61
|
+
const r = await terminalRun();
|
|
62
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
63
|
+
const res = await poll(r.runId, '');
|
|
64
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll unreachable (fetch failed)');
|
|
65
|
+
expect(res.status, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'pollRunEvents MUST answer 200')).toBe(200);
|
|
66
|
+
const body = res.json as Poll | undefined;
|
|
67
|
+
expect(Object.keys(body ?? {}).sort(), req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'the poll response MUST be exactly { runId, events, lastSequence, status, isTerminal } (closed)')).toEqual([...SHAPE].sort());
|
|
68
|
+
expect(body?.runId, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'runId MUST echo the polled run')).toBe(r.runId);
|
|
69
|
+
expect(Array.isArray(body?.events), req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'events MUST be an array')).toBe(true);
|
|
70
|
+
expect(Number.isInteger(body?.lastSequence) && (body?.lastSequence as number) >= -1, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'lastSequence is the highest sequence in the log (−1 when empty)')).toBe(true);
|
|
71
|
+
expect(typeof body?.status, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'status is the snapshot status')).toBe('string');
|
|
72
|
+
expect(typeof body?.isTerminal, req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'isTerminal is a boolean')).toBe('boolean');
|
|
73
|
+
const seqs = sequences(body);
|
|
74
|
+
if (seqs.length > 0) expect(Math.max(...seqs), req('openwop.requirement.0171.poll-cursor-v2.shape', DOC, 'lastSequence MUST equal the highest sequence returned when the response carries the whole log')).toBeLessThanOrEqual(body?.lastSequence as number);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('omitting the cursor returns from the first event (sequence 0)', async () => {
|
|
78
|
+
const r = await terminalRun();
|
|
79
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
80
|
+
const res = await poll(r.runId, '');
|
|
81
|
+
if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${res?.status ?? 'no response'}`);
|
|
82
|
+
const seqs = sequences(res.json);
|
|
83
|
+
if (seqs.length === 0) return softSkip('blocked', 'the poll returned no events for a run driven to terminal status — the first-event rule is unobservable');
|
|
84
|
+
expect(seqs[0], req('openwop.requirement.0171.poll-cursor-v2.from-first', DOC, 'omission of afterSequence means "from the first event": the first returned sequence MUST be 0')).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('afterSequence=N returns only events with sequence > N', async () => {
|
|
88
|
+
const r = await terminalRun();
|
|
89
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
90
|
+
const all = await poll(r.runId, '');
|
|
91
|
+
if (all === null || all.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${all?.status ?? 'no response'}`);
|
|
92
|
+
const seqs = sequences(all.json);
|
|
93
|
+
if (seqs.length === 0) return softSkip('blocked', 'the poll returned no events for a run driven to terminal status — the cursor rule is unobservable');
|
|
94
|
+
const n = seqs[0] as number;
|
|
95
|
+
const res = await poll(r.runId, `&afterSequence=${n}`);
|
|
96
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll?afterSequence= unreachable (fetch failed)');
|
|
97
|
+
expect(res.status, req('openwop.requirement.0171.poll-cursor-v2.after-sequence', DOC, 'afterSequence is an integer ≥ 0 and MUST be accepted')).toBe(200);
|
|
98
|
+
const after = sequences(res.json);
|
|
99
|
+
expect(after.every((s) => s > n), req('openwop.requirement.0171.poll-cursor-v2.after-sequence', DOC, `every returned sequence MUST be > afterSequence (${n}); got [${after.join(', ')}]`)).toBe(true);
|
|
100
|
+
expect(after, req('openwop.requirement.0171.poll-cursor-v2.after-sequence', DOC, 'the cursor is exclusive and the log is not renumbered: afterSequence=first yields exactly the rest of the log')).toEqual(seqs.filter((s) => s > n));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('a cursor past the end of the log returns 200 with an empty events array', async () => {
|
|
104
|
+
const r = await terminalRun();
|
|
105
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
106
|
+
const all = await poll(r.runId, '');
|
|
107
|
+
if (all === null || all.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${all?.status ?? 'no response'}`);
|
|
108
|
+
const last = Number((all.json as Poll | undefined)?.lastSequence);
|
|
109
|
+
const res = await poll(r.runId, `&afterSequence=${(Number.isFinite(last) ? last : 0) + 1000}`);
|
|
110
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll?afterSequence= unreachable (fetch failed)');
|
|
111
|
+
expect(res.status, req('openwop.requirement.0171.poll-cursor-v2.past-end', DOC, 'a cursor past the end of the log MUST return 200')).toBe(200);
|
|
112
|
+
expect((res.json as Poll | undefined)?.events, req('openwop.requirement.0171.poll-cursor-v2.past-end', DOC, 'a cursor past the end of the log MUST return an empty events array')).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `preferred-version-default` (suite 2.0.0; RFC 0172 §A.1, §A.3;
|
|
3
|
+
* `spec/v2/core/versioning.md` §1.1 "Advertisement", §1.3 "The request header";
|
|
4
|
+
* `spec/v2/core/capabilities.md` §1 "One well-known resource").
|
|
5
|
+
*
|
|
6
|
+
* Witness class: witnessable — unaided. `preferredVersion` MUST be a member of
|
|
7
|
+
* `protocolVersions[]`; a header-less request on an unversioned path MUST be
|
|
8
|
+
* served `preferredVersion`'s major, so a header-less `GET /.well-known/openwop`
|
|
9
|
+
* is the `preferredVersion` representation and its `OpenWOP-Version` response
|
|
10
|
+
* header names that major. The header-less fetch bypasses the driver (which
|
|
11
|
+
* stamps `OpenWOP-Version: 2.0` under target major 2).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { loadEnv } from '../lib/env.js';
|
|
16
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
17
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
18
|
+
import { req } from '../lib/requirement-ids.js';
|
|
19
|
+
|
|
20
|
+
const DOC = 'spec/v2/core/versioning.md §1.1';
|
|
21
|
+
const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
|
22
|
+
|
|
23
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function headerless(): Promise<{ status: number; version: string | null; json: unknown } | null> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`${loadEnv().baseUrl}/.well-known/openwop`, { headers: { Accept: 'application/json' } });
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
let json: unknown;
|
|
32
|
+
try { json = text.length > 0 ? JSON.parse(text) : undefined; } catch { json = undefined; }
|
|
33
|
+
return { status: res.status, version: res.headers.get('openwop-version'), json };
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('v2 preferred-version-default (RFC 0172 §A.1, §A.3)', () => {
|
|
40
|
+
it('preferredVersion is a member of protocolVersions[]', async () => {
|
|
41
|
+
const doc = await discovery();
|
|
42
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
43
|
+
const versions = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]) : [];
|
|
44
|
+
const preferred = doc['preferredVersion'];
|
|
45
|
+
expect(typeof preferred === 'string' && VERSION.test(preferred), req('openwop.requirement.0172.preferred-version-default.member', DOC, 'preferredVersion is REQUIRED root metadata with the <major>.<minor> grammar')).toBe(true);
|
|
46
|
+
expect(versions.includes(preferred), req('openwop.requirement.0172.preferred-version-default.member', DOC, `preferredVersion (${String(preferred)}) MUST be a member of protocolVersions[] [${versions.map(String).join(', ')}]`)).toBe(true);
|
|
47
|
+
if (versions.length === 1) {
|
|
48
|
+
expect(doc['protocolVersion'] === undefined || doc['protocolVersion'] === preferred, req('openwop.requirement.0172.preferred-version-default.member', DOC, 'on a host serving a single major, preferredVersion MUST equal protocolVersion (RFC 0179 §A.1)')).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('a header-less GET /.well-known/openwop is the preferredVersion representation', async () => {
|
|
53
|
+
const doc = await discovery();
|
|
54
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
55
|
+
const preferred = String(doc['preferredVersion']);
|
|
56
|
+
const res = await headerless();
|
|
57
|
+
if (res === null) return softSkip('blocked', 'header-less GET /.well-known/openwop unreachable (fetch failed)');
|
|
58
|
+
expect(res.status, req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/versioning.md §1.3', 'the discovery resource MUST answer a header-less request')).toBe(200);
|
|
59
|
+
const preferredMajor = preferred.split('.')[0];
|
|
60
|
+
expect(res.version?.trim().split('.')[0], req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/versioning.md §1.3', `with the header absent the host MUST serve preferredVersion's major (${preferredMajor}) and report it in OpenWOP-Version`)).toBe(preferredMajor);
|
|
61
|
+
const body = res.json as Record<string, unknown> | undefined;
|
|
62
|
+
if (preferredMajor === '2') {
|
|
63
|
+
const r = v2Validator('capabilities')(body);
|
|
64
|
+
expect(r.ok, req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/capabilities.md §1', `preferredVersion is 2.x, so the header-less representation MUST be the closed v2 root (${r.errors})`)).toBe(true);
|
|
65
|
+
} else {
|
|
66
|
+
expect(typeof body?.['protocolVersion'], req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/capabilities.md §1', 'preferredVersion is 1.x, so the header-less representation MUST be the v1 document (protocolVersion present) through the overlap')).toBe('string');
|
|
67
|
+
expect(Array.isArray(body?.['protocolVersions']) && typeof body?.['preferredVersion'] === 'string', req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/capabilities.md §1', 'the v1 representation MUST carry protocolVersions[] and preferredVersion additively so a single fetch names the other major')).toBe(true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it('through the overlap preferredVersion names a 1.x member', async () => {
|
|
71
|
+
const doc = await discovery();
|
|
72
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
73
|
+
const advertised = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]).map(String) : [];
|
|
74
|
+
if (!advertised.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `the host advertises no 1.x member (${advertised.join(', ') || 'none'}) — it is past the overlap, so preferredVersion is a 2.x and the header-less representation is the closed v2 root`);
|
|
75
|
+
// The header-less representation is the v1 document through the overlap
|
|
76
|
+
// (capabilities.md §1) and the header-less default is preferredVersion's
|
|
77
|
+
// major (§1.3); the two agree only when preferredVersion is the 1.x member.
|
|
78
|
+
expect(
|
|
79
|
+
String(doc['preferredVersion']).split('.')[0],
|
|
80
|
+
req('openwop.requirement.0172.preferred-version-default.overlap', 'spec/v2/core/versioning.md §1.1', `the host advertises a 1.x member (${advertised.join(', ')}), so preferredVersion MUST be that 1.x — otherwise versioning.md §1.3 and RFC 0176 §C.1 select different representations for the same header-less request (got ${String(doc['preferredVersion'])})`),
|
|
81
|
+
).toBe('1');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `profiles-derived-only` (suite 2.0.0; RFC 0169 §C.1, §C.3;
|
|
3
|
+
* `spec/v2/core/capabilities.md` §7 "Profiles").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. A profile is a predicate over the
|
|
6
|
+
* declaration file, published in `spec/v2/profiles.json`; it is never a wire
|
|
7
|
+
* field. The host's v2 document MUST NOT carry a root `profiles[]`, and every
|
|
8
|
+
* profile in the registry MUST be derivable from the document alone: every
|
|
9
|
+
* listed family present as a record and every listed metadata key present.
|
|
10
|
+
* `profiles.json` is read from the spec tree beside `SCHEMAS_DIR`; under a
|
|
11
|
+
* layout that does not ship it the derivation leg records `blocked`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
18
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
19
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
20
|
+
import { req } from '../lib/requirement-ids.js';
|
|
21
|
+
|
|
22
|
+
const DOC = 'spec/v2/core/capabilities.md §7';
|
|
23
|
+
const PROFILES_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'profiles.json');
|
|
24
|
+
|
|
25
|
+
interface ProfileRow { readonly id: string; readonly predicate: { readonly families?: readonly string[]; readonly metadata?: readonly string[] } }
|
|
26
|
+
|
|
27
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
28
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
32
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) && typeof (v as Record<string, unknown>)['status'] === 'string';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** RFC 0169 §C.1 — the predicate, evaluated over the document alone. */
|
|
36
|
+
function derive(doc: Record<string, unknown>, p: ProfileRow): boolean {
|
|
37
|
+
const families = p.predicate.families ?? [];
|
|
38
|
+
const metadata = p.predicate.metadata ?? [];
|
|
39
|
+
return families.every((f) => isRecord(doc[f])) && metadata.every((m) => doc[m] !== undefined);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('v2 profiles-derived-only (RFC 0169 §C.1, §C.3)', () => {
|
|
43
|
+
it('the v2 document carries no root `profiles[]`', async () => {
|
|
44
|
+
const doc = await discovery();
|
|
45
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
46
|
+
expect('profiles' in doc, req('openwop.requirement.0169.profiles-derived-only.no-root-profiles', DOC, 'no `profiles[]` exists at the v2 root; a profile is a derived predicate, never a wire field (row C2.10)')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('every registry profile is derivable from the document; the discovery-core predicate holds on a valid document', async () => {
|
|
50
|
+
const doc = await discovery();
|
|
51
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
52
|
+
if (!existsSync(PROFILES_PATH)) return softSkip('blocked', `spec/v2/profiles.json is not shipped in this layout (${PROFILES_PATH}) — the profile registry cannot be read`);
|
|
53
|
+
const rows = (JSON.parse(readFileSync(PROFILES_PATH, 'utf8')) as { profiles?: ProfileRow[] }).profiles ?? [];
|
|
54
|
+
expect(rows.length, req('openwop.requirement.0169.profiles-derived-only.derivable', DOC, 'spec/v2/profiles.json MUST list at least one profile')).toBeGreaterThan(0);
|
|
55
|
+
for (const p of rows) {
|
|
56
|
+
// Every listed family the host advertises MUST be a record — a profile
|
|
57
|
+
// predicate over a non-record value (a boolean, a bare object) is not derivable.
|
|
58
|
+
for (const f of p.predicate.families ?? []) {
|
|
59
|
+
if (doc[f] !== undefined) {
|
|
60
|
+
expect(isRecord(doc[f]), req('openwop.requirement.0169.profiles-derived-only.derivable', DOC, `profile ${p.id} lists family ${f}; the host advertises it, so it MUST be one capability record {status, since, witness, …}`)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
expect(typeof derive(doc, p), req('openwop.requirement.0169.profiles-derived-only.derivable', DOC, `profile ${p.id} MUST be decidable from the document alone`)).toBe('boolean');
|
|
64
|
+
}
|
|
65
|
+
const core = rows.find((p) => p.id === 'openwop-discovery-core');
|
|
66
|
+
if (core !== undefined) {
|
|
67
|
+
expect(derive(doc, core), req('openwop.requirement.0169.profiles-derived-only.derivable', DOC, 'openwop-discovery-core (metadata protocolVersions + preferredVersion, both REQUIRED at the root) MUST derive true from every valid v2 document')).toBe(true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|