@openwop/openwop-conformance 1.152.0 → 1.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +9 -0
- package/dist/cli.js +7 -2
- package/dist/lib/requirement-ids.js +100 -0
- package/dist/lib/scenario-disposition.js +22 -0
- package/package.json +33 -2
- package/requirement-aliases.json +4 -0
- package/requirements.json +23554 -0
- package/schemas/CORPUS-STAMP.json +99 -3
- package/src/cli.ts +7 -5
- package/src/global-setup.ts +13 -0
- package/src/lib/corpus-stamp.ts +125 -0
- package/src/lib/requirement-ids.ts +111 -0
- package/src/lib/scenario-disposition.ts +22 -0
- package/src/setup.ts +84 -5
- package/src/lib/capabilities-auth-subject-link.test.ts +0 -103
- package/src/lib/fork-availability.test.ts +0 -69
- package/src/lib/global-setup.test.ts +0 -76
- package/src/lib/grpc-framing.test.ts +0 -96
- package/src/lib/oidc-issuer.test.ts +0 -328
- package/src/lib/otel-collector-grpc.test.ts +0 -191
- package/src/lib/otel-collector.test.ts +0 -303
- package/src/lib/otlp-protobuf.test.ts +0 -461
- package/src/lib/polling.test.ts +0 -80
- package/src/lib/requirement-ledger.test.ts +0 -75
- package/src/lib/risk-disposition.test.ts +0 -91
- package/src/lib/saml-idp.test.ts +0 -127
- package/src/lib/spec-coherence-registry.test.ts +0 -155
- package/src/lib/webhook-receiver.test.ts +0 -144
- package/src/scenarios/artifact-schema-compile-bounded.test.ts +0 -126
- package/src/scenarios/artifact-type-legacy-ids.test.ts +0 -124
- package/src/scenarios/capability-example-root-layout.test.ts +0 -272
- package/src/scenarios/certification-floor-enforcement.test.ts +0 -204
- package/src/scenarios/chain-subchain-unsupported-refused.test.ts +0 -70
- package/src/scenarios/compensation-profile.test.ts +0 -340
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +0 -250
- package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +0 -219
- package/src/scenarios/edge-condition-truthy-falsy.test.ts +0 -108
- package/src/scenarios/effect-identity-composition.test.ts +0 -129
- package/src/scenarios/effect-identity-cross-scope.test.ts +0 -82
- package/src/scenarios/error-envelope-canonical-shape.test.ts +0 -64
- package/src/scenarios/form-content-packs.test.ts +0 -415
- package/src/scenarios/multi-region-effect-vocabulary.test.ts +0 -175
- package/src/scenarios/normative-example-extraction.test.ts +0 -242
- package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +0 -309
- package/src/scenarios/pack-manifest-extensions.test.ts +0 -203
- package/src/scenarios/protocol-version-grammar.test.ts +0 -119
- package/src/scenarios/registry-declarative-kinds.test.ts +0 -121
- package/src/scenarios/rfc-0147-self-audit.test.ts +0 -104
- package/src/scenarios/rfc-lifecycle-coherence.test.ts +0 -215
- package/src/scenarios/semantic-digest-v2.test.ts +0 -128
- package/src/scenarios/spec-corpus-validity.test.ts +0 -1727
- package/src/scenarios/spec-section-citations.test.ts +0 -132
- package/src/scenarios/tool-result-trust-monotone.test.ts +0 -168
- package/src/scenarios/versioned-composition-profiles.test.ts +0 -201
- package/src/scenarios/workflow-chain-internal-flag.test.ts +0 -84
- package/src/scenarios/workload-identity-profile.test.ts +0 -184
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-free pin of the RFC 0163 §C schema conditional in
|
|
3
|
-
* `schemas/capabilities.schema.json`:
|
|
4
|
-
*
|
|
5
|
-
* auth.subjectLinking: true ⇒ auth.subjectLinkKey required (RFC 0163)
|
|
6
|
-
* auth.subjectLinkKey ∈ { opaque-idp, configured-immutable } (RFC 0163)
|
|
7
|
-
* profiles ⊇ {saml, scim} ⇒ subjectLinking const true + key required (RFC 0164)
|
|
8
|
-
*
|
|
9
|
-
* This checks the SCHEMA ARTIFACT, not any host, which is why it lives under
|
|
10
|
-
* `src/lib/` (no scenario ledger row — see `saml-idp.test.ts` for the G8
|
|
11
|
-
* reasoning). The host-facing assertion is the advertisement leg of
|
|
12
|
-
* `scenarios/auth-subject-link-key-class.test.ts`, gated on the flag.
|
|
13
|
-
*
|
|
14
|
-
* @see RFCS/0163-subject-linking-hardening.md §C
|
|
15
|
-
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { describe, it, expect } from 'vitest';
|
|
19
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import Ajv2020 from 'ajv/dist/2020.js';
|
|
22
|
-
import addFormats from 'ajv-formats';
|
|
23
|
-
import { SCHEMAS_DIR } from './paths.js';
|
|
24
|
-
|
|
25
|
-
function compileCapabilities() {
|
|
26
|
-
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
27
|
-
addFormats(ajv);
|
|
28
|
-
for (const file of readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json'))) {
|
|
29
|
-
const s = JSON.parse(readFileSync(join(SCHEMAS_DIR, file), 'utf8')) as { $id?: string };
|
|
30
|
-
ajv.addSchema(s, file);
|
|
31
|
-
ajv.addSchema(s, `./${file}`);
|
|
32
|
-
}
|
|
33
|
-
// Validate the `auth` block in isolation: the conditional lives on that
|
|
34
|
-
// object, and a full discovery document would drag in every root-level
|
|
35
|
-
// required family (supportedEnvelopes, schemaVersions, limits, …) that has
|
|
36
|
-
// nothing to do with what this file pins.
|
|
37
|
-
return ajv.compile({ $ref: 'capabilities.schema.json#/properties/auth' });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const PROFILES = ['openwop-auth-saml', 'openwop-auth-scim'];
|
|
41
|
-
function doc(auth: Record<string, unknown>) {
|
|
42
|
-
return auth;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe('capabilities.schema.json: auth.subjectLinkKey conditional (RFC 0163 §C)', () => {
|
|
46
|
-
const validate = compileCapabilities();
|
|
47
|
-
|
|
48
|
-
it('subjectLinking:true WITHOUT subjectLinkKey fails validation (§A.1 co-requirement is in the schema, not only prose)', () => {
|
|
49
|
-
const ok = validate(doc({ profiles: PROFILES, subjectLinking: true }));
|
|
50
|
-
expect(ok, JSON.stringify(validate.errors)).toBe(false);
|
|
51
|
-
expect(JSON.stringify(validate.errors)).toContain('subjectLinkKey');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('subjectLinking:true with a class member validates', () => {
|
|
55
|
-
for (const k of ['opaque-idp', 'configured-immutable']) {
|
|
56
|
-
const ok = validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: k }));
|
|
57
|
-
expect(ok, `${k}: ${JSON.stringify(validate.errors)}`).toBe(true);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('a mutable/PII key is INEXPRESSIBLE: "email" fails the enum', () => {
|
|
62
|
-
const ok = validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: 'email' }));
|
|
63
|
-
expect(ok).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('the retired Draft-era attribute names are not members (the enum names classes)', () => {
|
|
67
|
-
for (const k of ['oid', 'immutable-id']) {
|
|
68
|
-
expect(validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: k })), k).toBe(false);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('one profile alone does not require the flag or a key (SAML-only / SCIM-only hosts are unaffected)', () => {
|
|
73
|
-
expect(validate(doc({ profiles: ['openwop-auth-saml'] }))).toBe(true);
|
|
74
|
-
expect(validate(doc({ profiles: ['openwop-auth-scim'], subjectLinking: false }))).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('capabilities.schema.json: the profile pair implies the contract (RFC 0164 §B)', () => {
|
|
79
|
-
const validate = compileCapabilities();
|
|
80
|
-
|
|
81
|
-
it('BOTH profiles with subjectLinking absent fails validation (the vulnerable pre-0164 shape)', () => {
|
|
82
|
-
const ok = validate(doc({ profiles: PROFILES }));
|
|
83
|
-
expect(ok, JSON.stringify(validate.errors)).toBe(false);
|
|
84
|
-
expect(JSON.stringify(validate.errors)).toContain('subjectLinking');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('BOTH profiles with subjectLinking:false fails validation (the flag is derived, not a choice)', () => {
|
|
88
|
-
expect(validate(doc({ profiles: PROFILES, subjectLinking: false, subjectLinkKey: 'opaque-idp' }))).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('BOTH profiles with subjectLinking:true but no subjectLinkKey fails validation', () => {
|
|
92
|
-
expect(validate(doc({ profiles: PROFILES, subjectLinking: true }))).toBe(false);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('BOTH profiles with subjectLinking:true + a class member validates (the derived advertisement)', () => {
|
|
96
|
-
expect(validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: 'opaque-idp' }))).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('profile order does not matter', () => {
|
|
100
|
-
expect(validate(doc({ profiles: ['openwop-auth-scim', 'openwop-auth-api-key-rotation', 'openwop-auth-saml'], subjectLinking: true, subjectLinkKey: 'configured-immutable' }))).toBe(true);
|
|
101
|
-
expect(validate(doc({ profiles: ['openwop-auth-scim', 'openwop-auth-api-key-rotation', 'openwop-auth-saml'] }))).toBe(false);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pins `forkDeclined` (RFC 0148 §A).
|
|
3
|
-
*
|
|
4
|
-
* The risk in a helper like this is not that it misses a status — that shows up
|
|
5
|
-
* as a red test somebody investigates. It is that it swallows one: every status
|
|
6
|
-
* it accepts turns an assertion into a skip, silently, and a suite that skips
|
|
7
|
-
* is indistinguishable from a suite that passes unless something records why.
|
|
8
|
-
* So the load-bearing cases here are the NEGATIVE ones.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, expect, it, beforeEach } from 'vitest';
|
|
12
|
-
import { forkDeclined } from './fork-availability.js';
|
|
13
|
-
import { resetSoftSkips, softSkipDisposition } from './soft-skip.js';
|
|
14
|
-
|
|
15
|
-
describe('forkDeclined', () => {
|
|
16
|
-
beforeEach(() => resetSoftSkips());
|
|
17
|
-
|
|
18
|
-
it('treats 404 and 403 the same as 501 — route absent is not weaker than route-declines', () => {
|
|
19
|
-
// The asymmetry this fixes: every replay scenario handled 501 and none
|
|
20
|
-
// handled 404. `501` means "I know this route and decline"; `404` means
|
|
21
|
-
// "there is no such route" — strictly less implemented. The suite treated
|
|
22
|
-
// the weaker signal as a skip and the stronger one as a defect, so a host
|
|
23
|
-
// had to implement the route in order to say it had not implemented it.
|
|
24
|
-
for (const status of [404, 403, 501]) {
|
|
25
|
-
expect(forkDeclined(status, 'leg'), `status ${status} means the fork did not happen`).toBe(true);
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('does NOT swallow any other status — a 500 is still a defect, not a skip', () => {
|
|
30
|
-
// If this ever goes green for one of these, a real failure has become a
|
|
31
|
-
// silent skip and no assertion downstream will ever run again.
|
|
32
|
-
for (const status of [200, 201, 202, 400, 409, 422, 500, 502, 503]) {
|
|
33
|
-
expect(forkDeclined(status, 'leg'), `status ${status} must reach the assertion`).toBe(false);
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('records WHY, so the ledger row is not an unclassified return', () => {
|
|
38
|
-
// Two of the eleven original call sites were a bare `return`. A bare return
|
|
39
|
-
// in a file whose OTHER tests assert is invisible: the file records
|
|
40
|
-
// `executed-pass` and the leg that never ran leaves no trace.
|
|
41
|
-
forkDeclined(404, 'fanout-suppression replay fork');
|
|
42
|
-
const noted = softSkipDisposition('fork-availability.test.ts');
|
|
43
|
-
expect(noted?.kind).toBe('blocked');
|
|
44
|
-
expect(noted?.reason).toContain('404');
|
|
45
|
-
expect(noted?.reason).toContain('fanout-suppression replay fork');
|
|
46
|
-
expect(noted?.reason).toContain('not mounted');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('records nothing when the status is not a decline', () => {
|
|
50
|
-
forkDeclined(201, 'leg');
|
|
51
|
-
expect(softSkipDisposition('fork-availability.test.ts')).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('names the leg, so one file with several forks stays attributable', () => {
|
|
55
|
-
forkDeclined(501, 'branch fork');
|
|
56
|
-
expect(softSkipDisposition('fork-availability.test.ts')?.reason).toContain('branch fork');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('distinguishes the three declines in its reason text', () => {
|
|
60
|
-
forkDeclined(404, 'a');
|
|
61
|
-
const a = softSkipDisposition('fork-availability.test.ts')?.reason ?? '';
|
|
62
|
-
resetSoftSkips();
|
|
63
|
-
forkDeclined(501, 'a');
|
|
64
|
-
const b = softSkipDisposition('fork-availability.test.ts')?.reason ?? '';
|
|
65
|
-
// `blocked` alone does not tell a bundle reader whether the host lacks the
|
|
66
|
-
// route or declines the range; the reason must.
|
|
67
|
-
expect(a).not.toBe(b);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pins the run-end disposition summary (`src/global-setup.ts`, RFC 0148 §A).
|
|
3
|
-
*
|
|
4
|
-
* The defect this guards is not "the summary is ugly" — it is that the suite
|
|
5
|
-
* computed an honest disposition on every run and PUBLISHED it on one. Two
|
|
6
|
-
* host implementers read vitest's `1 passed` as coverage for a requirement the
|
|
7
|
-
* suite had internally classified `blocked`, because the classification had no
|
|
8
|
-
* reader outside `--certify`.
|
|
9
|
-
*
|
|
10
|
-
* Server-free and pure: `summarise` takes the raw JSONL and returns the text.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { describe, expect, it } from 'vitest';
|
|
14
|
-
import { summarise } from '../global-setup.js';
|
|
15
|
-
|
|
16
|
-
const line = (o: Record<string, unknown>): string => JSON.stringify(o);
|
|
17
|
-
|
|
18
|
-
describe('global-setup: RFC 0148 §A disposition summary', () => {
|
|
19
|
-
it('reads the ledger key the ledger actually writes (`requirementId`, not `id`)', () => {
|
|
20
|
-
// Regression: the first draft of this reader keyed on `entry.id`, a field
|
|
21
|
-
// `recordRequirement` has never written, and fell back to `(unnamed)`.
|
|
22
|
-
// The fallback made a WRONG READER look like MISSING DATA — the same
|
|
23
|
-
// failure shape the summary exists to expose, reproduced in the exposer.
|
|
24
|
-
const out = summarise(
|
|
25
|
-
line({ requirementId: 'openwop.scenario.webhook-signed-delivery', disposition: 'blocked', detail: 'guard rejected loopback' }),
|
|
26
|
-
);
|
|
27
|
-
expect(out).toContain('openwop.scenario.webhook-signed-delivery');
|
|
28
|
-
expect(out).not.toContain('(unnamed)');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('lists blocked / skipped / inapplicable as NOT witnessed, with the reason', () => {
|
|
32
|
-
const out = summarise(
|
|
33
|
-
[
|
|
34
|
-
line({ requirementId: 'a', disposition: 'blocked', detail: 'seam absent' }),
|
|
35
|
-
line({ requirementId: 'b', disposition: 'skipped', detail: 'operator opted out' }),
|
|
36
|
-
line({ requirementId: 'c', disposition: 'inapplicable', detail: 'profile not advertised' }),
|
|
37
|
-
].join('\n'),
|
|
38
|
-
);
|
|
39
|
-
expect(out).toContain('did NOT witness');
|
|
40
|
-
for (const s of ['seam absent', 'operator opted out', 'profile not advertised']) {
|
|
41
|
-
expect(out).toContain(s);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('flags an executed-pass that asserted nothing — the other RFC 0148 §A vacuity shape', () => {
|
|
46
|
-
const out = summarise(line({ requirementId: 'vacuous', disposition: 'executed-pass', assertionCount: 0 }));
|
|
47
|
-
expect(out).toContain('executed-pass (0 assertions)');
|
|
48
|
-
expect(out).toContain('vacuous');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('does NOT flag a real executed-pass', () => {
|
|
52
|
-
const out = summarise(line({ requirementId: 'real', disposition: 'executed-pass', assertionCount: 12 }));
|
|
53
|
-
expect(out).toContain('executed-pass 1');
|
|
54
|
-
expect(out).not.toContain('did NOT witness');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('announces truncation rather than capping silently', () => {
|
|
58
|
-
// A capped list that does not say it was capped reads as a complete one.
|
|
59
|
-
const many = Array.from({ length: 45 }, (_, i) =>
|
|
60
|
-
line({ requirementId: `r${i}`, disposition: 'blocked', detail: 'why' }),
|
|
61
|
-
).join('\n');
|
|
62
|
-
const out = summarise(many);
|
|
63
|
-
expect(out).toContain('5 more not listed');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('returns null on an empty ledger so teardown prints nothing', () => {
|
|
67
|
-
expect(summarise('')).toBeNull();
|
|
68
|
-
expect(summarise('\n\n')).toBeNull();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('skips unparseable lines without discarding the rest of the run', () => {
|
|
72
|
-
const out = summarise(['{ not json', line({ requirementId: 'ok', disposition: 'blocked', detail: 'r' })].join('\n'));
|
|
73
|
-
expect(out).toContain('ok');
|
|
74
|
-
expect(out).toContain('blocked 1');
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for `grpc-framing.ts` — gRPC HTTP/2 message framing.
|
|
3
|
-
*
|
|
4
|
-
* @see grpc-framing.ts
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, it, expect } from 'vitest';
|
|
8
|
-
import { frameMessage, unframeMessages } from './grpc-framing.js';
|
|
9
|
-
|
|
10
|
-
describe('grpc-framing: frameMessage', () => {
|
|
11
|
-
it('prepends a 5-byte header to a single payload', () => {
|
|
12
|
-
const payload = new Uint8Array([0xab, 0xcd, 0xef]);
|
|
13
|
-
const framed = frameMessage(payload);
|
|
14
|
-
expect(framed.byteLength).toBe(8);
|
|
15
|
-
expect(framed[0]).toBe(0); // identity compression
|
|
16
|
-
expect(framed[1]).toBe(0);
|
|
17
|
-
expect(framed[2]).toBe(0);
|
|
18
|
-
expect(framed[3]).toBe(0);
|
|
19
|
-
expect(framed[4]).toBe(3); // length = 3
|
|
20
|
-
expect(framed[5]).toBe(0xab);
|
|
21
|
-
expect(framed[6]).toBe(0xcd);
|
|
22
|
-
expect(framed[7]).toBe(0xef);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('frames a zero-length payload as a 5-byte header alone', () => {
|
|
26
|
-
const framed = frameMessage(new Uint8Array(0));
|
|
27
|
-
expect(framed.byteLength).toBe(5);
|
|
28
|
-
expect(framed[0]).toBe(0);
|
|
29
|
-
expect(framed[4]).toBe(0);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('encodes lengths > 256 in big-endian order', () => {
|
|
33
|
-
const payload = new Uint8Array(300);
|
|
34
|
-
const framed = frameMessage(payload);
|
|
35
|
-
// length = 300 = 0x0000012C, big-endian: 00 00 01 2C
|
|
36
|
-
expect(framed[1]).toBe(0);
|
|
37
|
-
expect(framed[2]).toBe(0);
|
|
38
|
-
expect(framed[3]).toBe(1);
|
|
39
|
-
expect(framed[4]).toBe(0x2c);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('grpc-framing: unframeMessages', () => {
|
|
44
|
-
it('parses a single frame', () => {
|
|
45
|
-
const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
|
|
46
|
-
const framed = frameMessage(payload);
|
|
47
|
-
const messages = unframeMessages(framed);
|
|
48
|
-
expect(messages.length).toBe(1);
|
|
49
|
-
expect(Array.from(messages[0]!)).toEqual([0x01, 0x02, 0x03, 0x04]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('parses multiple concatenated frames', () => {
|
|
53
|
-
const a = frameMessage(new Uint8Array([0xaa, 0xab]));
|
|
54
|
-
const b = frameMessage(new Uint8Array([0xbb]));
|
|
55
|
-
const c = frameMessage(new Uint8Array([0xcc, 0xcd, 0xce, 0xcf]));
|
|
56
|
-
const combined = new Uint8Array(a.byteLength + b.byteLength + c.byteLength);
|
|
57
|
-
combined.set(a, 0);
|
|
58
|
-
combined.set(b, a.byteLength);
|
|
59
|
-
combined.set(c, a.byteLength + b.byteLength);
|
|
60
|
-
const messages = unframeMessages(combined);
|
|
61
|
-
expect(messages.length).toBe(3);
|
|
62
|
-
expect(Array.from(messages[0]!)).toEqual([0xaa, 0xab]);
|
|
63
|
-
expect(Array.from(messages[1]!)).toEqual([0xbb]);
|
|
64
|
-
expect(Array.from(messages[2]!)).toEqual([0xcc, 0xcd, 0xce, 0xcf]);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('parses an empty buffer as zero frames', () => {
|
|
68
|
-
expect(unframeMessages(new Uint8Array(0))).toEqual([]);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('throws on truncated header', () => {
|
|
72
|
-
// Only 3 bytes of a 5-byte header.
|
|
73
|
-
const buf = new Uint8Array([0, 0, 0]);
|
|
74
|
-
expect(() => unframeMessages(buf)).toThrow(/frame truncated/i);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('throws on truncated payload', () => {
|
|
78
|
-
// 5-byte header declares 10 bytes of payload but only 4 follow.
|
|
79
|
-
const buf = new Uint8Array([0, 0, 0, 0, 10, 1, 2, 3, 4]);
|
|
80
|
-
expect(() => unframeMessages(buf)).toThrow(/payload truncated/i);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('throws on unsupported compression flag', () => {
|
|
84
|
-
// Flag = 1 → compression negotiated, which we don't implement.
|
|
85
|
-
const buf = new Uint8Array([1, 0, 0, 0, 0]);
|
|
86
|
-
expect(() => unframeMessages(buf)).toThrow(/compression flag/i);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('round-trips: frame → unframe yields original payload', () => {
|
|
90
|
-
const original = new Uint8Array([0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f]); // protobuf "hello"
|
|
91
|
-
const framed = frameMessage(original);
|
|
92
|
-
const unframed = unframeMessages(framed);
|
|
93
|
-
expect(unframed.length).toBe(1);
|
|
94
|
-
expect(Array.from(unframed[0]!)).toEqual(Array.from(original));
|
|
95
|
-
});
|
|
96
|
-
});
|
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-free unit tests for the synthetic OIDC issuer harness.
|
|
3
|
-
*
|
|
4
|
-
* The harness is real cryptographic code (RS256 + ES256 JWS signing,
|
|
5
|
-
* JWKS export, JWT compact serialization). If the signing or encoding
|
|
6
|
-
* is wrong, every scenario that uses the harness silently misreports —
|
|
7
|
-
* the OIDC validation scenarios soft-skip behavior portions when the
|
|
8
|
-
* host doesn't trust the harness, so a malformed token would simply
|
|
9
|
-
* cause the host to reject and the test to "pass" via soft-skip path.
|
|
10
|
-
*
|
|
11
|
-
* These unit tests round-trip every token through `node:crypto.createVerify`
|
|
12
|
-
* to confirm the harness output is parseable by an independent verifier.
|
|
13
|
-
* Run server-free; doesn't depend on OPENWOP_BASE_URL.
|
|
14
|
-
*
|
|
15
|
-
* @see conformance/src/lib/oidc-issuer.ts
|
|
16
|
-
* @see RFCS/0010-auth-profile-conformance.md §E
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { describe, it, expect } from 'vitest';
|
|
20
|
-
import { createPublicKey, createVerify, type JsonWebKey } from 'node:crypto';
|
|
21
|
-
import { createSyntheticOIDCIssuer } from './oidc-issuer.js';
|
|
22
|
-
|
|
23
|
-
function base64UrlDecode(input: string): Buffer {
|
|
24
|
-
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
|
|
25
|
-
const padded = input + '='.repeat(pad);
|
|
26
|
-
const std = padded.replace(/-/g, '+').replace(/_/g, '/');
|
|
27
|
-
return Buffer.from(std, 'base64');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function decodeJwt(token: string): {
|
|
31
|
-
header: Record<string, unknown>;
|
|
32
|
-
payload: Record<string, unknown>;
|
|
33
|
-
signature: Buffer;
|
|
34
|
-
signingInput: string;
|
|
35
|
-
} {
|
|
36
|
-
const parts = token.split('.');
|
|
37
|
-
if (parts.length !== 3) throw new Error(`malformed JWT: ${parts.length} segments`);
|
|
38
|
-
const [h, p, s] = parts;
|
|
39
|
-
return {
|
|
40
|
-
header: JSON.parse(base64UrlDecode(h).toString('utf8')) as Record<string, unknown>,
|
|
41
|
-
payload: JSON.parse(base64UrlDecode(p).toString('utf8')) as Record<string, unknown>,
|
|
42
|
-
signature: base64UrlDecode(s),
|
|
43
|
-
signingInput: `${h}.${p}`,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function verifyToken(
|
|
48
|
-
token: string,
|
|
49
|
-
jwksJson: string,
|
|
50
|
-
algorithm: 'RS256' | 'ES256',
|
|
51
|
-
): boolean {
|
|
52
|
-
const decoded = decodeJwt(token);
|
|
53
|
-
const jwks = JSON.parse(jwksJson) as { keys: JsonWebKey[] };
|
|
54
|
-
const kid = decoded.header.kid;
|
|
55
|
-
const key = jwks.keys.find((k) => k.kid === kid);
|
|
56
|
-
if (!key) throw new Error(`no JWKS key matches kid=${String(kid)}`);
|
|
57
|
-
|
|
58
|
-
const publicKey = createPublicKey({ key, format: 'jwk' });
|
|
59
|
-
const digest = algorithm === 'RS256' ? 'RSA-SHA256' : 'SHA256';
|
|
60
|
-
const verifier = createVerify(digest);
|
|
61
|
-
verifier.update(decoded.signingInput);
|
|
62
|
-
verifier.end();
|
|
63
|
-
return verifier.verify(
|
|
64
|
-
algorithm === 'RS256'
|
|
65
|
-
? publicKey
|
|
66
|
-
: { key: publicKey, dsaEncoding: 'ieee-p1363' },
|
|
67
|
-
decoded.signature,
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
describe('oidc-issuer: harness construction', () => {
|
|
72
|
-
it('requires issuer and audience', () => {
|
|
73
|
-
expect(() =>
|
|
74
|
-
createSyntheticOIDCIssuer({ issuer: '', audience: 'openwop' }),
|
|
75
|
-
).toThrow(/issuer and audience/);
|
|
76
|
-
expect(() =>
|
|
77
|
-
createSyntheticOIDCIssuer({ issuer: 'https://x', audience: '' }),
|
|
78
|
-
).toThrow(/issuer and audience/);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('defaults to RS256 + canonical keyId', () => {
|
|
82
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
83
|
-
issuer: 'https://harness.example',
|
|
84
|
-
audience: 'openwop',
|
|
85
|
-
});
|
|
86
|
-
expect(issuer.algorithm).toBe('RS256');
|
|
87
|
-
expect(issuer.keyId).toBe('openwop-conformance-key-1');
|
|
88
|
-
expect(issuer.issuer).toBe('https://harness.example');
|
|
89
|
-
expect(issuer.audience).toBe('openwop');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('rejects unsupported algorithm at runtime (defensive)', () => {
|
|
93
|
-
expect(() =>
|
|
94
|
-
createSyntheticOIDCIssuer({
|
|
95
|
-
issuer: 'https://x',
|
|
96
|
-
audience: 'y',
|
|
97
|
-
algorithm: 'HS256',
|
|
98
|
-
}),
|
|
99
|
-
).toThrow(/unsupported algorithm/);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe('oidc-issuer: JWKS + discovery shape', () => {
|
|
104
|
-
it('publishes a well-formed JWKS for RS256', () => {
|
|
105
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
106
|
-
issuer: 'https://harness.example',
|
|
107
|
-
audience: 'openwop',
|
|
108
|
-
});
|
|
109
|
-
const jwks = JSON.parse(issuer.jwksJson) as { keys: JsonWebKey[] };
|
|
110
|
-
expect(Array.isArray(jwks.keys)).toBe(true);
|
|
111
|
-
expect(jwks.keys.length).toBe(1);
|
|
112
|
-
const key = jwks.keys[0];
|
|
113
|
-
expect(key.kty).toBe('RSA');
|
|
114
|
-
expect(key.alg).toBe('RS256');
|
|
115
|
-
expect(key.use).toBe('sig');
|
|
116
|
-
expect(key.kid).toBe(issuer.keyId);
|
|
117
|
-
// RSA JWK MUST have n (modulus) and e (exponent).
|
|
118
|
-
expect(typeof key.n).toBe('string');
|
|
119
|
-
expect(typeof key.e).toBe('string');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('publishes a well-formed JWKS for ES256', () => {
|
|
123
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
124
|
-
issuer: 'https://harness.example',
|
|
125
|
-
audience: 'openwop',
|
|
126
|
-
algorithm: 'ES256',
|
|
127
|
-
});
|
|
128
|
-
const jwks = JSON.parse(issuer.jwksJson) as { keys: JsonWebKey[] };
|
|
129
|
-
const key = jwks.keys[0];
|
|
130
|
-
expect(key.kty).toBe('EC');
|
|
131
|
-
expect(key.alg).toBe('ES256');
|
|
132
|
-
expect(key.crv).toBe('P-256');
|
|
133
|
-
expect(typeof key.x).toBe('string');
|
|
134
|
-
expect(typeof key.y).toBe('string');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('publishes OIDC discovery doc with correct shape', () => {
|
|
138
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
139
|
-
issuer: 'https://harness.example/oauth',
|
|
140
|
-
audience: 'openwop',
|
|
141
|
-
});
|
|
142
|
-
const disco = JSON.parse(issuer.discoveryJson) as {
|
|
143
|
-
issuer: string;
|
|
144
|
-
jwks_uri: string;
|
|
145
|
-
response_types_supported: string[];
|
|
146
|
-
subject_types_supported: string[];
|
|
147
|
-
id_token_signing_alg_values_supported: string[];
|
|
148
|
-
};
|
|
149
|
-
expect(disco.issuer).toBe('https://harness.example/oauth');
|
|
150
|
-
expect(disco.jwks_uri).toBe('https://harness.example/oauth/.well-known/jwks.json');
|
|
151
|
-
expect(disco.id_token_signing_alg_values_supported).toContain('RS256');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('discovery doc strips trailing slash before appending jwks path', () => {
|
|
155
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
156
|
-
issuer: 'https://harness.example/',
|
|
157
|
-
audience: 'openwop',
|
|
158
|
-
});
|
|
159
|
-
const disco = JSON.parse(issuer.discoveryJson) as { jwks_uri: string };
|
|
160
|
-
expect(disco.jwks_uri).toBe('https://harness.example/.well-known/jwks.json');
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe('oidc-issuer: mint defaults', () => {
|
|
165
|
-
it('fills iss / aud / iat / exp when not supplied', () => {
|
|
166
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
167
|
-
issuer: 'https://harness.example',
|
|
168
|
-
audience: 'openwop',
|
|
169
|
-
});
|
|
170
|
-
const before = Math.floor(Date.now() / 1000);
|
|
171
|
-
const { claims } = issuer.mint({ sub: 'test-sub' });
|
|
172
|
-
const after = Math.floor(Date.now() / 1000);
|
|
173
|
-
|
|
174
|
-
expect(claims.iss).toBe('https://harness.example');
|
|
175
|
-
expect(claims.aud).toBe('openwop');
|
|
176
|
-
expect(claims.sub).toBe('test-sub');
|
|
177
|
-
expect(typeof claims.iat).toBe('number');
|
|
178
|
-
expect(typeof claims.exp).toBe('number');
|
|
179
|
-
expect(claims.iat).toBeGreaterThanOrEqual(before);
|
|
180
|
-
expect(claims.iat).toBeLessThanOrEqual(after);
|
|
181
|
-
// Default lifetime is 300s.
|
|
182
|
-
expect((claims.exp as number) - (claims.iat as number)).toBe(300);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('caller claims override defaults', () => {
|
|
186
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
187
|
-
issuer: 'https://harness.example',
|
|
188
|
-
audience: 'openwop',
|
|
189
|
-
});
|
|
190
|
-
const { claims } = issuer.mint({
|
|
191
|
-
iss: 'override-issuer',
|
|
192
|
-
aud: 'override-audience',
|
|
193
|
-
sub: 'test-sub',
|
|
194
|
-
});
|
|
195
|
-
expect(claims.iss).toBe('override-issuer');
|
|
196
|
-
expect(claims.aud).toBe('override-audience');
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('negative expiresInSeconds mints already-expired token', () => {
|
|
200
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
201
|
-
issuer: 'https://harness.example',
|
|
202
|
-
audience: 'openwop',
|
|
203
|
-
});
|
|
204
|
-
const now = Math.floor(Date.now() / 1000);
|
|
205
|
-
const { claims } = issuer.mint(
|
|
206
|
-
{ sub: 'test-sub' },
|
|
207
|
-
{ expiresInSeconds: -3600 },
|
|
208
|
-
);
|
|
209
|
-
expect((claims.exp as number) < now).toBe(true);
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
describe('oidc-issuer: signature round-trip', () => {
|
|
214
|
-
it('RS256 token verifies against published JWKS', () => {
|
|
215
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
216
|
-
issuer: 'https://harness.example',
|
|
217
|
-
audience: 'openwop',
|
|
218
|
-
algorithm: 'RS256',
|
|
219
|
-
});
|
|
220
|
-
const { token } = issuer.mint({ sub: 'test-sub' });
|
|
221
|
-
const verified = verifyToken(token, issuer.jwksJson, 'RS256');
|
|
222
|
-
expect(verified).toBe(true);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('ES256 token verifies against published JWKS', () => {
|
|
226
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
227
|
-
issuer: 'https://harness.example',
|
|
228
|
-
audience: 'openwop',
|
|
229
|
-
algorithm: 'ES256',
|
|
230
|
-
});
|
|
231
|
-
const { token } = issuer.mint({ sub: 'test-sub' });
|
|
232
|
-
const verified = verifyToken(token, issuer.jwksJson, 'ES256');
|
|
233
|
-
expect(verified).toBe(true);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('header alg matches issuer algorithm by default', () => {
|
|
237
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
238
|
-
issuer: 'https://harness.example',
|
|
239
|
-
audience: 'openwop',
|
|
240
|
-
algorithm: 'ES256',
|
|
241
|
-
});
|
|
242
|
-
const { token } = issuer.mint({ sub: 'test-sub' });
|
|
243
|
-
const decoded = decodeJwt(token);
|
|
244
|
-
expect(decoded.header.alg).toBe('ES256');
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('mint opts.algorithm override appears in header (alg-spoof scenario)', () => {
|
|
248
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
249
|
-
issuer: 'https://harness.example',
|
|
250
|
-
audience: 'openwop',
|
|
251
|
-
algorithm: 'RS256',
|
|
252
|
-
});
|
|
253
|
-
const { token } = issuer.mint(
|
|
254
|
-
{ sub: 'test-sub' },
|
|
255
|
-
{ algorithm: 'HS256' },
|
|
256
|
-
);
|
|
257
|
-
const decoded = decodeJwt(token);
|
|
258
|
-
expect(decoded.header.alg).toBe('HS256');
|
|
259
|
-
// The signature is still RS256-bytes (the harness doesn't actually
|
|
260
|
-
// honor the alg override for the signature itself — that's the spoof:
|
|
261
|
-
// the header lies, the bytes don't match). Verification with RS256
|
|
262
|
-
// succeeds, which is the test scenario's correct behavior: it lets
|
|
263
|
-
// the OAuth2-CC negative-case scenario assert the host rejects
|
|
264
|
-
// because the header claims HS256 outside supportedAlgorithms.
|
|
265
|
-
const verified = verifyToken(
|
|
266
|
-
// Pull alg from header for verification — but the verify path
|
|
267
|
-
// is RS256 because that's the actual key. Re-decode and verify
|
|
268
|
-
// by extracting the alg-from-issuer rather than alg-from-header.
|
|
269
|
-
token,
|
|
270
|
-
issuer.jwksJson,
|
|
271
|
-
'RS256',
|
|
272
|
-
);
|
|
273
|
-
expect(verified).toBe(true);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it('keyId override sets header.kid without changing signing key (unknown-kid scenario)', () => {
|
|
277
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
278
|
-
issuer: 'https://harness.example',
|
|
279
|
-
audience: 'openwop',
|
|
280
|
-
});
|
|
281
|
-
const { token } = issuer.mint(
|
|
282
|
-
{ sub: 'test-sub' },
|
|
283
|
-
{ keyId: 'never-published-kid' },
|
|
284
|
-
);
|
|
285
|
-
const decoded = decodeJwt(token);
|
|
286
|
-
expect(decoded.header.kid).toBe('never-published-kid');
|
|
287
|
-
// The JWKS doesn't publish this kid; verifyToken throws.
|
|
288
|
-
expect(() => verifyToken(token, issuer.jwksJson, 'RS256')).toThrow(/no JWKS key/);
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
describe('oidc-issuer: key rotation', () => {
|
|
293
|
-
it('rotateKey() changes the published keyId', () => {
|
|
294
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
295
|
-
issuer: 'https://harness.example',
|
|
296
|
-
audience: 'openwop',
|
|
297
|
-
});
|
|
298
|
-
const firstKid = issuer.keyId;
|
|
299
|
-
issuer.rotateKey();
|
|
300
|
-
expect(issuer.keyId).not.toBe(firstKid);
|
|
301
|
-
expect(issuer.keyId).toBe('openwop-conformance-key-2');
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('tokens minted before rotation no longer verify against new JWKS', () => {
|
|
305
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
306
|
-
issuer: 'https://harness.example',
|
|
307
|
-
audience: 'openwop',
|
|
308
|
-
});
|
|
309
|
-
const beforeRotation = issuer.mint({ sub: 'test-sub' });
|
|
310
|
-
issuer.rotateKey();
|
|
311
|
-
// The JWKS now publishes a different key. The old token's header
|
|
312
|
-
// kid still references the pre-rotation kid, which isn't published.
|
|
313
|
-
expect(() =>
|
|
314
|
-
verifyToken(beforeRotation.token, issuer.jwksJson, 'RS256'),
|
|
315
|
-
).toThrow(/no JWKS key/);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('tokens minted after rotation verify against new JWKS', () => {
|
|
319
|
-
const issuer = createSyntheticOIDCIssuer({
|
|
320
|
-
issuer: 'https://harness.example',
|
|
321
|
-
audience: 'openwop',
|
|
322
|
-
});
|
|
323
|
-
issuer.rotateKey();
|
|
324
|
-
const afterRotation = issuer.mint({ sub: 'test-sub' });
|
|
325
|
-
const verified = verifyToken(afterRotation.token, issuer.jwksJson, 'RS256');
|
|
326
|
-
expect(verified).toBe(true);
|
|
327
|
-
});
|
|
328
|
-
});
|