@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,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the closed/transferred predicate in
|
|
3
|
-
* `scripts/generate-assurance-status.mjs`.
|
|
4
|
-
*
|
|
5
|
-
* This predicate is not cosmetic. Project-wide gates have been keyed to the
|
|
6
|
-
* open-Critical count it produces, so a false closure silently loosens a
|
|
7
|
-
* constraint — and a false open silently keeps one in force.
|
|
8
|
-
*
|
|
9
|
-
* It previously matched the bare substring `closed` anywhere in a row's status
|
|
10
|
-
* cell, which produced two failures in opposite directions:
|
|
11
|
-
*
|
|
12
|
-
* · RFC 0151 R1 ("Compensation executes twice", Critical) reads
|
|
13
|
-
* "Open — ... unwitnessed" and was counted CLOSED from 2026-08-16 onward,
|
|
14
|
-
* because the cell mentions "(G1 closed 2026-08-16)" — a DIFFERENT item's
|
|
15
|
-
* closure. A substring of an adjacent concept.
|
|
16
|
-
* · A row stating that a risk "cannot be closed by repository work" was
|
|
17
|
-
* counted closed by saying so.
|
|
18
|
-
*
|
|
19
|
-
* The predicate is duplicated here rather than imported because the generator is
|
|
20
|
-
* a standalone ESM script with no exports; the duplication is pinned by
|
|
21
|
-
* `matches the generator's source` below, which fails if the two drift.
|
|
22
|
-
*
|
|
23
|
-
* @see scripts/generate-assurance-status.mjs
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { describe, it, expect } from 'vitest';
|
|
27
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
28
|
-
import { join, dirname } from 'node:path';
|
|
29
|
-
import { V1_DIR } from './paths.js';
|
|
30
|
-
|
|
31
|
-
const NEGATED = /\b(cannot|can ?not|could not|will not|never|not)\s+be\s+(closed|resolved)\b|\bnot closed\b/i;
|
|
32
|
-
const EXPLICIT = /\*\*(CLOSED|Closed)\b|~~|Realised and remediated/i;
|
|
33
|
-
const TRANSFERRED = /\*\*(?:OPEN\s+—\s+)?TRANSFERRED\b/i;
|
|
34
|
-
|
|
35
|
-
function disposition(status: string): 'closed' | 'transferred' | 'open' {
|
|
36
|
-
const closed = EXPLICIT.test(status) && !NEGATED.test(status);
|
|
37
|
-
if (closed) return 'closed';
|
|
38
|
-
return TRANSFERRED.test(status) ? 'transferred' : 'open';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('assurance risk disposition', () => {
|
|
42
|
-
it('does NOT read a nested reference to another item as this row being closed', () => {
|
|
43
|
-
// The exact RFC 0151 R1 shape. "Open", "unwitnessed", and a parenthetical
|
|
44
|
-
// about gap G1 closing — a different thing entirely.
|
|
45
|
-
const status =
|
|
46
|
-
'Open — **Sweep 2026-08-16:** **Mitigated in prose** — inverse-action identity tuple stated; ' +
|
|
47
|
-
'`compensation.md` §C now states the persistence shape (G1 closed 2026-08-16); unwitnessed for retry-stability';
|
|
48
|
-
expect(disposition(status)).toBe('open');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('does NOT read a row that says a risk cannot be closed as closed', () => {
|
|
52
|
-
expect(disposition('**OPEN — TRANSFERRED.** This risk cannot be closed by repository work.')).not.toBe('closed');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('reads an explicit marker as closed', () => {
|
|
56
|
-
expect(disposition('**CLOSED.** The recurrence mechanism is in the tree and executing.')).toBe('closed');
|
|
57
|
-
expect(disposition('~~superseded~~ — folded into RFC 0150 §D')).toBe('closed');
|
|
58
|
-
expect(disposition('**Realised and remediated in scope:** bundle 1 invalidated')).toBe('closed');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('distinguishes transferred from open, because §A.1 turns on the difference', () => {
|
|
62
|
-
expect(disposition('**OPEN — TRANSFERRED to a named tracked surface.** Tracked in KNOWN-LIMITS.')).toBe('transferred');
|
|
63
|
-
expect(disposition('Open — unwitnessed. No host advertises the capability.')).toBe('open');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('is not fooled by the word appearing in ordinary prose', () => {
|
|
67
|
-
expect(disposition('Open — the comment window closed without review.')).toBe('open');
|
|
68
|
-
expect(disposition('Open — closes when a host implements fencing.')).toBe('open');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('matches the generator source — fails if the two drift apart', () => {
|
|
72
|
-
// `V1_DIR` is null in the PUBLISHED package layout, which ships no `spec/`
|
|
73
|
-
// and no `scripts/`. Resolving the path at module scope — or casting the
|
|
74
|
-
// null away — is what made six scenarios throw at import for every npm
|
|
75
|
-
// consumer while staying green in a repo checkout. Resolve inside the test
|
|
76
|
-
// and skip when the repo is not there.
|
|
77
|
-
if (V1_DIR === null) return;
|
|
78
|
-
const script = join(dirname(V1_DIR), '..', 'scripts', 'generate-assurance-status.mjs');
|
|
79
|
-
if (!existsSync(script)) return;
|
|
80
|
-
const src = readFileSync(script, 'utf8');
|
|
81
|
-
// Guard the shape, not the byte-for-byte text: the generator must still gate
|
|
82
|
-
// on an explicit marker AND a negation check, never on a bare substring.
|
|
83
|
-
expect(src, 'generator must keep the negation guard').toMatch(/const negated = /);
|
|
84
|
-
expect(src, 'generator must require an explicit closed marker').toMatch(/const explicitlyClosed =/);
|
|
85
|
-
expect(src, 'generator must still separate transferred rows').toMatch(/const transferred = /);
|
|
86
|
-
expect(
|
|
87
|
-
/const closed = \/\(\^\|\\s\)\(closed\|resolved/.test(src),
|
|
88
|
-
'the bare-substring test must not come back',
|
|
89
|
-
).toBe(false);
|
|
90
|
-
});
|
|
91
|
-
});
|
package/src/lib/saml-idp.test.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-free unit tests for the synthetic SAML IdP harness — specifically its
|
|
3
|
-
* TWO-TRUST-ROOT behaviour (RFC 0163 §B, gap G-fixture, #1163).
|
|
4
|
-
*
|
|
5
|
-
* These assertions prove the FIXTURE, not any host: two instances with distinct
|
|
6
|
-
* `entityID`s and signing keys can mint an assertion for the SAME opaque
|
|
7
|
-
* subject id (an identifier collision) while remaining cryptographically and
|
|
8
|
-
* by-issuer distinguishable. That is what makes the §B same-IdP MUST
|
|
9
|
-
* witnessable — a host comparing the SAML Issuer to the SCIM connection's IdP
|
|
10
|
-
* entityID has a well-defined, causable negative.
|
|
11
|
-
*
|
|
12
|
-
* They live here rather than in `scenarios/auth-subject-link-key-class.test.ts`
|
|
13
|
-
* on purpose: a scenario file that passes fixture self-tests and soft-skips its
|
|
14
|
-
* host legs resolves to `executed-pass` in every host's certification bundle
|
|
15
|
-
* (conformance-certification.md gap G8), which would credit a host that never
|
|
16
|
-
* advertised subject linking with a witness about a fixture. `src/lib/` tests
|
|
17
|
-
* run in the published suite but produce no scenario ledger row, exactly like
|
|
18
|
-
* `oidc-issuer.test.ts`.
|
|
19
|
-
*
|
|
20
|
-
* @see conformance/src/lib/saml-idp.ts
|
|
21
|
-
* @see RFCS/0163-subject-linking-hardening.md §B
|
|
22
|
-
*
|
|
23
|
-
* The RFC 0050 §A reference suite (1 positive + 6 negative variants against the
|
|
24
|
-
* same fixture) moved here from `scenarios/auth-saml-profile.test.ts` on
|
|
25
|
-
* 2026-09-02 (RFC 0163 gap G5) for the same reason.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { describe, it, expect } from 'vitest';
|
|
29
|
-
import { createSyntheticSamlIdp, DEFAULT_SAML_ENTITY_ID, type SamlVariant } from './saml-idp.js';
|
|
30
|
-
|
|
31
|
-
const COLLIDING_SUBJECT = 'idp-op-8f3a';
|
|
32
|
-
|
|
33
|
-
describe('saml-idp: two-trust-root fixture (RFC 0163 §B)', () => {
|
|
34
|
-
const idpA = createSyntheticSamlIdp({ entityID: 'urn:openwop:conformance:idp-A' });
|
|
35
|
-
const idpB = createSyntheticSamlIdp({ entityID: 'urn:openwop:conformance:idp-B' });
|
|
36
|
-
|
|
37
|
-
it('a lone instance keeps the canonical default entityID (RFC 0050 behaviour unchanged)', () => {
|
|
38
|
-
const lone = createSyntheticSamlIdp();
|
|
39
|
-
expect(lone.entityID).toBe(DEFAULT_SAML_ENTITY_ID);
|
|
40
|
-
expect(lone.verify(lone.mint('valid')).valid).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('the two IdPs have distinct entityIDs and distinct signing keys (distinct trust roots)', () => {
|
|
44
|
-
expect(idpA.entityID).not.toBe(idpB.entityID);
|
|
45
|
-
expect(idpA.certificatePem).not.toBe(idpB.certificatePem);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('both IdPs can mint a valid assertion for the SAME colliding subject id', () => {
|
|
49
|
-
const a = idpA.verify(idpA.mint('valid', { subject: COLLIDING_SUBJECT }));
|
|
50
|
-
const b = idpB.verify(idpB.mint('valid', { subject: COLLIDING_SUBJECT }));
|
|
51
|
-
expect(a.valid, `IdP-A must accept its own assertion; got ${a.reason}`).toBe(true);
|
|
52
|
-
expect(b.valid, `IdP-B must accept its own assertion; got ${b.reason}`).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('the colliding assertions carry DIFFERENT signed Issuers (the trust-root discriminator)', () => {
|
|
56
|
-
const assertionA = idpA.mint('valid', { subject: COLLIDING_SUBJECT });
|
|
57
|
-
const assertionB = idpB.mint('valid', { subject: COLLIDING_SUBJECT });
|
|
58
|
-
expect(idpA.issuerOf(assertionA)).toBe('urn:openwop:conformance:idp-A');
|
|
59
|
-
expect(idpB.issuerOf(assertionB)).toBe('urn:openwop:conformance:idp-B');
|
|
60
|
-
expect(idpA.issuerOf(assertionA)).not.toBe(idpB.issuerOf(assertionB));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('IdP-A REJECTS an assertion minted by IdP-B for the same subject (cross-root does not verify)', () => {
|
|
64
|
-
// The crux of RFC 0163 §B: an identifier collision across two trust roots
|
|
65
|
-
// is NOT a link. IdP-B signs with a different key AND stamps a different
|
|
66
|
-
// Issuer, so IdP-A's validator refuses it — a match on the opaque subject
|
|
67
|
-
// string alone can never authorize across the roots.
|
|
68
|
-
const crossRoot = idpB.mint('valid', { subject: COLLIDING_SUBJECT });
|
|
69
|
-
const r = idpA.verify(crossRoot);
|
|
70
|
-
expect(r.valid, 'an assertion from a DIFFERENT trust root MUST NOT verify against this IdP').toBe(false);
|
|
71
|
-
expect(r.reason).toBe('bad-signature');
|
|
72
|
-
expect(idpA.issuerOf(crossRoot)).not.toBe(idpA.entityID);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('the Issuer is inside the signed element: swapping it post-signing breaks verification', () => {
|
|
76
|
-
// RFC 0163 §B.1 requires the trust-root identity to be signed so it cannot
|
|
77
|
-
// be rewritten to impersonate another root. Rewrite IdP-B's Issuer to
|
|
78
|
-
// IdP-A's entityID on an otherwise valid IdP-B assertion: the issuer now
|
|
79
|
-
// READS as A, but the signature was computed over B's canonical form.
|
|
80
|
-
const forged = idpB
|
|
81
|
-
.mint('valid', { subject: COLLIDING_SUBJECT })
|
|
82
|
-
.replace('<saml:Issuer>urn:openwop:conformance:idp-B</saml:Issuer>', '<saml:Issuer>urn:openwop:conformance:idp-A</saml:Issuer>');
|
|
83
|
-
expect(idpA.issuerOf(forged)).toBe(idpA.entityID);
|
|
84
|
-
expect(idpA.verify(forged).valid, 'a re-stamped Issuer MUST NOT verify').toBe(false);
|
|
85
|
-
expect(idpB.verify(forged).valid, 'the original root MUST NOT verify it either').toBe(false);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('saml-idp: synthetic-IdP reference suite (RFC 0050 §A)', () => {
|
|
90
|
-
// Server-free: the bundled synthetic IdP (conformance/src/lib/saml-idp.ts)
|
|
91
|
-
// mints a valid assertion + the 6 negative variants, and its verify()
|
|
92
|
-
// implements the RFC 0050 §A MUST list. This proves each negative is
|
|
93
|
-
// detectably malformed and gives the suite a reference SAML validator.
|
|
94
|
-
// A host's real ACS validates the SAME assertions over the
|
|
95
|
-
// `auth/saml/validate` seam (scenarios/auth-saml-profile.test.ts, gated on
|
|
96
|
-
// OPENWOP_TEST_SAML_IDP_URL).
|
|
97
|
-
const idp = createSyntheticSamlIdp();
|
|
98
|
-
|
|
99
|
-
it('publishes a PEM signing certificate', () => {
|
|
100
|
-
expect(idp.certificatePem).toContain('BEGIN PUBLIC KEY');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('accepts a valid signed, in-window, non-wrapped assertion', () => {
|
|
104
|
-
const r = idp.verify(idp.mint('valid'));
|
|
105
|
-
expect(r.valid, `expected valid; got reason=${r.reason}`).toBe(true);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const negatives: ReadonlyArray<[Exclude<SamlVariant, 'valid'>, string]> = [
|
|
109
|
-
['alg-none', 'alg-none'],
|
|
110
|
-
['unsigned', 'unsigned'],
|
|
111
|
-
['bad-signature', 'bad-signature'],
|
|
112
|
-
['expired', 'expired'],
|
|
113
|
-
['not-yet-valid', 'not-yet-valid'],
|
|
114
|
-
['signature-wrapping', 'signature-wrapping'],
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
for (const [variant, expectedReason] of negatives) {
|
|
118
|
-
it(`rejects the ${variant} assertion (RFC 0050 §A MUST)`, () => {
|
|
119
|
-
const r = idp.verify(idp.mint(variant));
|
|
120
|
-
expect(r.valid, `${variant} MUST be rejected`).toBe(false);
|
|
121
|
-
expect(
|
|
122
|
-
r.reason,
|
|
123
|
-
`${variant} MUST be rejected for the ${expectedReason} reason`,
|
|
124
|
-
).toBe(expectedReason);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
});
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keeps `SPEC_COHERENCE_SCENARIOS` honest by re-deriving it from source.
|
|
3
|
-
*
|
|
4
|
-
* A hand-maintained list of filenames is a claim that decays silently: a new
|
|
5
|
-
* spec-coherence scenario lands and reports `blocked` in every host's bundle
|
|
6
|
-
* forever, or one grows a `driver` call and starts telling hosts a requirement
|
|
7
|
-
* about their own behaviour does not apply to them. Neither shows up as a
|
|
8
|
-
* failure anywhere — which is the whole reason the original defect survived.
|
|
9
|
-
*
|
|
10
|
-
* The membership rule is mechanical, so the check can be too:
|
|
11
|
-
* gates on `V1_DIR === null` AND never calls `driver.get/post/delete`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, expect, it } from 'vitest';
|
|
15
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
-
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL, SPEC_COHERENCE_EXCLUDED } from './spec-coherence.js';
|
|
18
|
-
import { resolveFileRecord } from './scenario-disposition.js';
|
|
19
|
-
|
|
20
|
-
const SCENARIOS = new URL('../scenarios/', import.meta.url).pathname;
|
|
21
|
-
|
|
22
|
-
function derive(): { pure: string[]; hostTouching: string[] } {
|
|
23
|
-
const pure: string[] = [];
|
|
24
|
-
const hostTouching: string[] = [];
|
|
25
|
-
for (const f of readdirSync(SCENARIOS)) {
|
|
26
|
-
if (!f.endsWith('.test.ts')) continue;
|
|
27
|
-
const src = readFileSync(join(SCENARIOS, f), 'utf8');
|
|
28
|
-
if (!/V1_DIR\s*===?\s*null/.test(src)) continue;
|
|
29
|
-
(/\bdriver\.(get|post|delete)\b/.test(src) ? hostTouching : pure).push(f);
|
|
30
|
-
}
|
|
31
|
-
return { pure: pure.sort(), hostTouching: hostTouching.sort() };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe('SPEC_COHERENCE_SCENARIOS is derivable, not asserted', () => {
|
|
35
|
-
it('matches every scenario that reads spec/v1 and never drives a host', () => {
|
|
36
|
-
const { pure } = derive();
|
|
37
|
-
const listed = [...SPEC_COHERENCE_SCENARIOS].sort();
|
|
38
|
-
// Named diffs rather than a bare inequality: a failure here should say
|
|
39
|
-
// which file to add or drop, not that two sets differ.
|
|
40
|
-
expect(pure.filter((f) => !SPEC_COHERENCE_SCENARIOS.has(f)), 'reads spec/v1, drives no host, NOT in the registry — it will report `blocked` in every host bundle').toEqual([]);
|
|
41
|
-
expect(listed.filter((f) => !pure.includes(f)), 'in the registry but no longer qualifies — it now drives a host, or stopped reading spec/v1').toEqual([]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('the EXCLUDED set is exactly the host-touching ones — the exclusions are checked, not asserted in prose', () => {
|
|
45
|
-
// These used to live only in a docblock sentence. A peer grepping this file
|
|
46
|
-
// for membership matched that sentence and read all three named scenarios
|
|
47
|
-
// as members — the opposite of the truth, and the dangerous direction: it
|
|
48
|
-
// would mean host-behaviour rows downgraded to "does not apply to you" as a
|
|
49
|
-
// credit. Naming them in prose made the file lie to a reasonable reader.
|
|
50
|
-
const { hostTouching } = derive();
|
|
51
|
-
expect([...SPEC_COHERENCE_EXCLUDED].sort()).toEqual(hostTouching);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('the two sets are disjoint and jointly exhaustive over the V1_DIR-gated files', () => {
|
|
55
|
-
// Disjoint: no scenario can be both "does not apply to any host" and
|
|
56
|
-
// "applies but was unwitnessable". Exhaustive: every V1_DIR-gated file has
|
|
57
|
-
// a decided disposition, so none falls back to the unclassified marker.
|
|
58
|
-
const { pure, hostTouching } = derive();
|
|
59
|
-
const overlap = [...SPEC_COHERENCE_SCENARIOS].filter((f) => SPEC_COHERENCE_EXCLUDED.has(f));
|
|
60
|
-
expect(overlap, 'a scenario cannot be both inapplicable-to-all-hosts and blocked-for-this-host').toEqual([]);
|
|
61
|
-
const union = new Set([...SPEC_COHERENCE_SCENARIOS, ...SPEC_COHERENCE_EXCLUDED]);
|
|
62
|
-
expect([...union].sort()).toEqual([...pure, ...hostTouching].sort());
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('excludes the host-touching ones, which are honestly `blocked`', () => {
|
|
66
|
-
// These assert ADVERTISED behaviour that a missing dependency prevented
|
|
67
|
-
// exercising — RFC 0148 §A's definition of `blocked`, verbatim. Calling
|
|
68
|
-
// them `inapplicable` would tell a host a requirement about its own
|
|
69
|
-
// behaviour does not apply to it.
|
|
70
|
-
const { hostTouching } = derive();
|
|
71
|
-
expect(hostTouching.length, 'expected some V1_DIR-gated scenarios to also drive the host').toBeGreaterThan(0);
|
|
72
|
-
for (const f of hostTouching) {
|
|
73
|
-
expect(SPEC_COHERENCE_SCENARIOS.has(f), `${f} drives a host and must NOT be classified inapplicable`).toBe(false);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('the registry is non-empty — an empty set would silently disable the fix', () => {
|
|
78
|
-
expect(SPEC_COHERENCE_SCENARIOS.size).toBeGreaterThan(20);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('resolveFileRecord classifies a corpus scenario as inapplicable, not blocked', () => {
|
|
83
|
-
// A published-layout run: describe.skipIf fires at COLLECTION, so vitest
|
|
84
|
-
// reports the file's tests as skipped, nothing notes a reason, and before
|
|
85
|
-
// this change resolveFileRecord returned `blocked` with the unclassified
|
|
86
|
-
// marker — the row a host operator could not tell from a real gap.
|
|
87
|
-
const CORPUS = 'protocol-version-grammar.test.ts';
|
|
88
|
-
|
|
89
|
-
it('a corpus scenario that never ran is inapplicable, with a reason aimed at the host operator', () => {
|
|
90
|
-
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, CORPUS);
|
|
91
|
-
expect(r.disposition).toBe('inapplicable');
|
|
92
|
-
expect(r.detail).toBe(SPEC_COHERENCE_DETAIL);
|
|
93
|
-
expect(r.detail).toContain('asserts nothing about a host');
|
|
94
|
-
expect(r.detail).toContain('OPENWOP_CONFORMANCE_ROOT');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('WITHOUT the registry it would still be blocked — the branch is what changes it', () => {
|
|
98
|
-
// Same inputs, filename withheld: the pre-change behaviour. This is the
|
|
99
|
-
// negative control; if it ever returns `inapplicable`, the branch is not
|
|
100
|
-
// what is doing the work and the test above proves nothing.
|
|
101
|
-
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null);
|
|
102
|
-
expect(r.disposition).toBe('blocked');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('a non-corpus scenario is untouched', () => {
|
|
106
|
-
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, 'webhook-signed-delivery.test.ts');
|
|
107
|
-
expect(r.disposition).toBe('blocked');
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('a corpus scenario that FAILED is never laundered into inapplicable', () => {
|
|
111
|
-
// The guard that matters: if the corpus IS present and an assertion fails,
|
|
112
|
-
// that is a real spec-coherence defect and must stay executed-fail.
|
|
113
|
-
const r = resolveFileRecord(['pass', 'fail'], undefined, 12, null, CORPUS);
|
|
114
|
-
expect(r.disposition).toBe('executed-fail');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('a corpus scenario that RAN and passed stays executed-pass', () => {
|
|
118
|
-
const r = resolveFileRecord(['pass'], undefined, 40, null, CORPUS);
|
|
119
|
-
expect(r.disposition).toBe('executed-pass');
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe('the published layout is what makes these rows comparable across hosts', () => {
|
|
124
|
-
// Load-bearing and, until now, tested nowhere.
|
|
125
|
-
//
|
|
126
|
-
// A spec-coherence row is `inapplicable` only when V1_DIR is null, and V1_DIR
|
|
127
|
-
// is null only when the layout resolves to `published`. Two peers established
|
|
128
|
-
// by measurement what the code implies: `resolveLayout()` keys off PKG_ROOT,
|
|
129
|
-
// not the consuming repo, so an npm-installed consumer's parent is always
|
|
130
|
-
// `node_modules/@openwop/` — which never contains `schemas/` no matter where
|
|
131
|
-
// the host's checkout sits on disk. Every npm-consuming host therefore gets
|
|
132
|
-
// the same answer, and the dispositions are comparable BY CONSTRUCTION.
|
|
133
|
-
//
|
|
134
|
-
// One peer had generalised the opposite way — "the disposition is a property
|
|
135
|
-
// of where the bundle was cut" — from a host whose own runner sets
|
|
136
|
-
// OPENWOP_CONFORMANCE_ROOT when it finds a sibling checkout. True of that
|
|
137
|
-
// host, false of the artifact. The distinction only survives if something
|
|
138
|
-
// holds the artifact to it.
|
|
139
|
-
//
|
|
140
|
-
// The thing that would break it is a change that looks HELPFUL: adding
|
|
141
|
-
// `spec` to `files` so "the corpus tests run for consumers too". That would
|
|
142
|
-
// silently give npm consumers V1_DIR, the 28 would execute instead of
|
|
143
|
-
// flipping, and every host's numbers would shift with no failure anywhere.
|
|
144
|
-
it('the published package ships no spec/ — so V1_DIR is null for npm consumers', () => {
|
|
145
|
-
const pkg = JSON.parse(readFileSync(join(SCENARIOS, '../../package.json'), 'utf8')) as { files: string[] };
|
|
146
|
-
expect(
|
|
147
|
-
pkg.files.filter((f) => f === 'spec' || f.startsWith('spec/')),
|
|
148
|
-
'adding spec/ to `files` would give npm consumers a V1_DIR, so the spec-coherence rows would execute '
|
|
149
|
-
+ 'instead of reporting `inapplicable` — changing every host bundle with no test going red',
|
|
150
|
-
).toEqual([]);
|
|
151
|
-
// schemas/ IS shipped, and is what selects the `published` layout. If this
|
|
152
|
-
// ever stops being true the layout resolves to neither branch.
|
|
153
|
-
expect(pkg.files, 'schemas/ is what makes resolveLayout() pick `published`').toContain('schemas');
|
|
154
|
-
});
|
|
155
|
-
});
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for `webhook-receiver.ts` — the signature-header contract.
|
|
3
|
-
*
|
|
4
|
-
* This verifier is the reference a subscriber implementer copies. It required
|
|
5
|
-
* `v1=` and therefore rejected, as malformed, the exact header `webhooks.md`
|
|
6
|
-
* §"Delivery headers" mandates (`X-openwop-Signature: sha256={hex}`). The
|
|
7
|
-
* divergence survived because `webhook-receiver-adversarial.test.ts` signs with
|
|
8
|
-
* `signPayload` and verifies with `verifyWebhookDelivery` — a closed loop that is
|
|
9
|
-
* self-consistent and wrong, and so green against every host. These cases pin the
|
|
10
|
-
* header against the SPEC rather than against the suite's own output.
|
|
11
|
-
*
|
|
12
|
-
* @see webhook-receiver.ts, spec/v1/webhooks.md §"Delivery headers"
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
16
|
-
import { createHmac } from 'node:crypto';
|
|
17
|
-
import {
|
|
18
|
-
SIGNATURE_PREFIX,
|
|
19
|
-
createReceiverState,
|
|
20
|
-
verifyWebhookDelivery,
|
|
21
|
-
signPayload,
|
|
22
|
-
resolveRegistrationUrl,
|
|
23
|
-
} from './webhook-receiver.js';
|
|
24
|
-
|
|
25
|
-
const SECRET = 'shhh-not-a-real-secret';
|
|
26
|
-
const BODY = JSON.stringify({ event: { type: 'run.completed' } });
|
|
27
|
-
|
|
28
|
-
/** Build the header exactly as `webhooks.md` documents it, not as we emit it. */
|
|
29
|
-
function specShapedHeader(ts: number): string {
|
|
30
|
-
const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
|
|
31
|
-
return `sha256=${hex}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe('webhook-receiver: the X-openwop-Signature prefix follows the spec', () => {
|
|
35
|
-
it('the constant is the spec value, not the algorithm-header value', () => {
|
|
36
|
-
// `v1` names the SIGNING SCHEME (X-openwop-Signature-Algorithm). It is not
|
|
37
|
-
// the encoding prefix. One value, two fields — the conflation this fixes.
|
|
38
|
-
expect(SIGNATURE_PREFIX).toBe('sha256=');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('ACCEPTS a header built to the spec by hand, with no help from signPayload', () => {
|
|
42
|
-
const ts = Math.floor(Date.now() / 1000);
|
|
43
|
-
const result = verifyWebhookDelivery(
|
|
44
|
-
SECRET,
|
|
45
|
-
specShapedHeader(ts),
|
|
46
|
-
'v1',
|
|
47
|
-
String(ts),
|
|
48
|
-
BODY,
|
|
49
|
-
createReceiverState(),
|
|
50
|
-
);
|
|
51
|
-
expect(result.accepted).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('REJECTS the pre-2026-08-19 `v1=` prefix as malformed — the shape the spec never defined', () => {
|
|
55
|
-
const ts = Math.floor(Date.now() / 1000);
|
|
56
|
-
const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
|
|
57
|
-
const result = verifyWebhookDelivery(
|
|
58
|
-
SECRET,
|
|
59
|
-
`v1=${hex}`,
|
|
60
|
-
'v1',
|
|
61
|
-
String(ts),
|
|
62
|
-
BODY,
|
|
63
|
-
createReceiverState(),
|
|
64
|
-
);
|
|
65
|
-
expect(result.accepted).toBe(false);
|
|
66
|
-
if (!result.accepted) expect(result.reason).toBe('malformed_signature_header');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('signPayload emits what the verifier accepts AND what the spec documents', () => {
|
|
70
|
-
const ts = Math.floor(Date.now() / 1000);
|
|
71
|
-
const { signatureHeader, algorithmHeader } = signPayload(SECRET, ts, BODY);
|
|
72
|
-
// Both halves matter: agreeing with the verifier alone is the closed loop
|
|
73
|
-
// that hid the bug, so this also compares against the hand-built header.
|
|
74
|
-
expect(signatureHeader).toBe(specShapedHeader(ts));
|
|
75
|
-
expect(algorithmHeader).toBe('v1');
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* `OPENWOP_WEBHOOK_RECEIVER_URL` validation.
|
|
81
|
-
*
|
|
82
|
-
* Each case names the gate it is about, taken from `webhooks.md`, rather than
|
|
83
|
-
* restating the implementation: the point of the variable is to clear all three
|
|
84
|
-
* SSRF gates honestly, so a value that cannot clear one of them is an operator
|
|
85
|
-
* error the suite must refuse LOUDLY. A skip here would hide a
|
|
86
|
-
* misconfiguration behind a disposition that reads as "the host could not be
|
|
87
|
-
* exercised", which is a claim about the host and would be false.
|
|
88
|
-
*/
|
|
89
|
-
describe('resolveRegistrationUrl — OPENWOP_WEBHOOK_RECEIVER_URL', () => {
|
|
90
|
-
const LOCAL = 'http://127.0.0.1:54321/';
|
|
91
|
-
const saved = process.env.OPENWOP_WEBHOOK_RECEIVER_URL;
|
|
92
|
-
const set = (v: string | undefined) => {
|
|
93
|
-
if (v === undefined) delete process.env.OPENWOP_WEBHOOK_RECEIVER_URL;
|
|
94
|
-
else process.env.OPENWOP_WEBHOOK_RECEIVER_URL = v;
|
|
95
|
-
};
|
|
96
|
-
afterEach(() => set(saved));
|
|
97
|
-
|
|
98
|
-
it('unset ⇒ registers the local receiver unchanged, not tunnelled', () => {
|
|
99
|
-
set(undefined);
|
|
100
|
-
expect(resolveRegistrationUrl(LOCAL)).toEqual({ url: LOCAL, tunnelled: false });
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('whitespace-only is treated as unset rather than as a malformed URL', () => {
|
|
104
|
-
set(' ');
|
|
105
|
-
expect(resolveRegistrationUrl(LOCAL)).toEqual({ url: LOCAL, tunnelled: false });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('a public https front is used for registration and marked tunnelled', () => {
|
|
109
|
-
set('https://tunnel.example.com/hook');
|
|
110
|
-
expect(resolveRegistrationUrl(LOCAL)).toEqual({
|
|
111
|
-
url: 'https://tunnel.example.com/hook',
|
|
112
|
-
tunnelled: true,
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('rejects http: — cannot clear gate 1 (webhooks.md §"Register": url MUST be https)', () => {
|
|
117
|
-
set('http://tunnel.example.com/hook');
|
|
118
|
-
expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/MUST be https/i);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it.each([
|
|
122
|
-
['loopback name', 'https://localhost/hook'],
|
|
123
|
-
['loopback v4', 'https://127.0.0.1/hook'],
|
|
124
|
-
['RFC1918 10/8', 'https://10.1.2.3/hook'],
|
|
125
|
-
['RFC1918 192.168/16', 'https://192.168.1.9/hook'],
|
|
126
|
-
['RFC1918 172.16/12', 'https://172.20.0.5/hook'],
|
|
127
|
-
['link-local', 'https://169.254.169.254/hook'],
|
|
128
|
-
])('rejects %s — cannot clear gate 2 (registration-time address check)', (_label, url) => {
|
|
129
|
-
set(url);
|
|
130
|
-
expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/publicly-resolvable/i);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('rejects a value that is not a URL at all', () => {
|
|
134
|
-
set('not a url');
|
|
135
|
-
expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/not a valid URL/i);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('does not reject a public host that merely LOOKS private (172.32 is public)', () => {
|
|
139
|
-
// 172.16.0.0/12 ends at 172.31.255.255. A naive /^172\./ check would
|
|
140
|
-
// reject this and send an operator hunting a nonexistent misconfiguration.
|
|
141
|
-
set('https://172.32.0.1/hook');
|
|
142
|
-
expect(resolveRegistrationUrl(LOCAL).tunnelled).toBe(true);
|
|
143
|
-
});
|
|
144
|
-
});
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounded artifact-schema compilation (RFC 0071, `Active`).
|
|
3
|
-
*
|
|
4
|
-
* Always-on, server-free assertion for the SECURITY invariant
|
|
5
|
-
* `artifact-schema-compile-bounded`. Artifact-type packs ship third-party
|
|
6
|
-
* JSON Schemas that the engine compiles (Ajv) at install + validation time;
|
|
7
|
-
* an unbounded compile is a denial-of-service vector (schema bombs:
|
|
8
|
-
* pathological `$ref` recursion, keyword-count explosion, oversized payloads,
|
|
9
|
-
* catastrophic-backtracking `pattern`s). This scenario asserts two things
|
|
10
|
-
* that must hold for every release regardless of which host runs it:
|
|
11
|
-
*
|
|
12
|
-
* PART 1 — contract present. `artifact-type-packs.md` carries the normative
|
|
13
|
-
* bounded-compilation MUST (serialized-size, `$ref`-depth, keyword-count
|
|
14
|
-
* bounds + wall-clock timeout), and `host-capabilities.md` §host.artifactTypes
|
|
15
|
-
* references it. Guards against the requirement being silently dropped.
|
|
16
|
-
*
|
|
17
|
-
* PART 2 — defense is well-defined + implementable. A reference bounding
|
|
18
|
-
* predicate built from representative finite limits rejects three schema
|
|
19
|
-
* bombs and admits a benign artifact schema. The specific numeric limits are
|
|
20
|
-
* host-configurable per the spec (advertised, not protocol-mandated); the
|
|
21
|
-
* point is that *some* finite bound exists and catches the bombs while
|
|
22
|
-
* passing legitimate schemas.
|
|
23
|
-
*
|
|
24
|
-
* The behavioral end-to-end form (a host rejects an over-bounds pack at
|
|
25
|
-
* registry `PUT` with `pack_validation_failed`) is capability-gated on
|
|
26
|
-
* `host.artifactTypes.supported` and is `host-pending` until a reference host
|
|
27
|
-
* lands; this server-free scenario is the always-on floor.
|
|
28
|
-
*
|
|
29
|
-
* @see spec/v1/artifact-type-packs.md §"Bounded schema compilation (normative)"
|
|
30
|
-
* @see SECURITY/threat-model-node-packs.md §"Distributed artifact schemas"
|
|
31
|
-
* @see RFCS/0071-artifact-type-and-chat-card-packs.md
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { describe, it, expect } from 'vitest';
|
|
35
|
-
import { readFileSync } from 'node:fs';
|
|
36
|
-
import { join } from 'node:path';
|
|
37
|
-
import { V1_DIR } from '../lib/paths.js';
|
|
38
|
-
|
|
39
|
-
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
-
|
|
41
|
-
describe('artifact-schema-compile-bounded: contract present in the corpus (RFC 0071, server-free)', () => {
|
|
42
|
-
const artifactDoc = V1_DIR ? readFileSync(join(V1_DIR, 'artifact-type-packs.md'), 'utf8') : '';
|
|
43
|
-
const hostCaps = V1_DIR ? readFileSync(join(V1_DIR, 'host-capabilities.md'), 'utf8') : '';
|
|
44
|
-
|
|
45
|
-
it.skipIf(V1_DIR === null)('artifact-type-packs.md declares the bounded-compilation MUST', () => {
|
|
46
|
-
expect(
|
|
47
|
-
/Bounded schema compilation/i.test(artifactDoc),
|
|
48
|
-
why('artifact-type-packs.md', 'a "Bounded schema compilation" section MUST exist'),
|
|
49
|
-
).toBe(true);
|
|
50
|
-
expect(
|
|
51
|
-
/MUST bound/i.test(artifactDoc) && /MUST reject/i.test(artifactDoc),
|
|
52
|
-
why('artifact-type-packs.md §"Bounded schema compilation"', 'host MUST bound + MUST reject over-limit schemas'),
|
|
53
|
-
).toBe(true);
|
|
54
|
-
// The three structural axes + the timeout MUST all be named.
|
|
55
|
-
for (const axis of [/byte size/i, /\$ref/i, /keyword/i, /timeout/i]) {
|
|
56
|
-
expect(axis.test(artifactDoc), why('artifact-type-packs.md', `bound axis ${axis} MUST be named`)).toBe(true);
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it.skipIf(V1_DIR === null)('host-capabilities.md §host.artifactTypes references the bound', () => {
|
|
61
|
-
expect(
|
|
62
|
-
/artifact-schema-compile-bounded/.test(hostCaps),
|
|
63
|
-
why('host-capabilities.md §host.artifactTypes', 'MUST reference the bounded-compilation invariant'),
|
|
64
|
-
).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('artifact-schema-compile-bounded: a finite bound catches schema bombs (RFC 0071, server-free)', () => {
|
|
69
|
-
// Representative, host-configurable limits (the spec leaves the exact values
|
|
70
|
-
// to host advertisement; these stand in for "some finite bound").
|
|
71
|
-
const LIMITS = { maxBytes: 64 * 1024, maxRefDepth: 16, maxKeywords: 2000 };
|
|
72
|
-
|
|
73
|
-
function refDepth(node: unknown, seen = 0): number {
|
|
74
|
-
if (node === null || typeof node !== 'object') return seen;
|
|
75
|
-
const obj = node as Record<string, unknown>;
|
|
76
|
-
const here = '$ref' in obj ? seen + 1 : seen;
|
|
77
|
-
let max = here;
|
|
78
|
-
for (const v of Object.values(obj)) max = Math.max(max, refDepth(v, here));
|
|
79
|
-
return max;
|
|
80
|
-
}
|
|
81
|
-
function keywordCount(node: unknown): number {
|
|
82
|
-
if (node === null || typeof node !== 'object') return 0;
|
|
83
|
-
const obj = node as Record<string, unknown>;
|
|
84
|
-
let n = Object.keys(obj).length;
|
|
85
|
-
for (const v of Object.values(obj)) n += keywordCount(v);
|
|
86
|
-
return n;
|
|
87
|
-
}
|
|
88
|
-
/** Reference bound predicate — the shape a conformant host applies at PUT/install. */
|
|
89
|
-
function exceedsBounds(schema: unknown): boolean {
|
|
90
|
-
const bytes = Buffer.byteLength(JSON.stringify(schema), 'utf8');
|
|
91
|
-
if (bytes > LIMITS.maxBytes) return true;
|
|
92
|
-
if (refDepth(schema) > LIMITS.maxRefDepth) return true;
|
|
93
|
-
if (keywordCount(schema) > LIMITS.maxKeywords) return true;
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
it('admits a benign artifact schema', () => {
|
|
98
|
-
const benign = {
|
|
99
|
-
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
100
|
-
$id: 'https://h.example/schemas/artifacts/vendor.acme.cad.model.schema.json',
|
|
101
|
-
type: 'object',
|
|
102
|
-
additionalProperties: false,
|
|
103
|
-
required: ['name'],
|
|
104
|
-
properties: { name: { type: 'string' }, dims: { type: 'array', items: { type: 'number' } } },
|
|
105
|
-
};
|
|
106
|
-
expect(exceedsBounds(benign), why('artifact-type-packs.md', 'a legitimate artifact schema MUST NOT be rejected')).toBe(false);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('rejects a $ref-depth bomb', () => {
|
|
110
|
-
// Nest $ref-bearing objects deeper than maxRefDepth so resolution depth accumulates.
|
|
111
|
-
let node: Record<string, unknown> = { type: 'string' };
|
|
112
|
-
for (let i = 0; i < LIMITS.maxRefDepth + 4; i++) node = { $ref: '#/x', properties: { nested: node } };
|
|
113
|
-
expect(exceedsBounds({ type: 'object', properties: { deep: node } }), why('threat-model-node-packs.md', 'a $ref-depth bomb MUST be rejected')).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('rejects a keyword-count bomb', () => {
|
|
117
|
-
const props: Record<string, unknown> = {};
|
|
118
|
-
for (let i = 0; i < LIMITS.maxKeywords + 100; i++) props[`p${i}`] = { type: 'string' };
|
|
119
|
-
expect(exceedsBounds({ type: 'object', properties: props }), why('threat-model-node-packs.md', 'a keyword-count bomb MUST be rejected')).toBe(true);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('rejects an oversized schema', () => {
|
|
123
|
-
const huge = { type: 'object', description: 'x'.repeat(LIMITS.maxBytes + 1) };
|
|
124
|
-
expect(exceedsBounds(huge), why('threat-model-node-packs.md', 'an over-size schema MUST be rejected')).toBe(true);
|
|
125
|
-
});
|
|
126
|
-
});
|