/** * Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04 * `--apply` failure recorded in `docs/09` Part 3. * * The first case is that failure verbatim: the `SecurityHeadersConfig` the live * `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and * all, which is what `create-response-headers-policy` rejected. * * node infra/cloudfront/policy-shapes.test.mjs */ import { withoutEmptyMembers, emptyObjectPaths, isEmptyObject, limitViolations, PAYLOAD_LIMITS, } from './policy-shapes.mjs'; let pass = 0; const failures = []; const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); const t = (name, got, want) => { if (eq(got, want)) pass += 1; else failures.push( `${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`, ); }; /* The live source policy, copied from `get-response-headers-policy` on 67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */ const LIVE_SECURITY_HEADERS = { XSSProtection: { Override: false, Protection: true, ModeBlock: true }, FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' }, ReferrerPolicy: { Override: false, ReferrerPolicy: 'strict-origin-when-cross-origin', }, ContentSecurityPolicy: {}, ContentTypeOptions: { Override: true }, StrictTransportSecurity: { Override: false, AccessControlMaxAgeSec: 31536000, }, }; /* ---- the incident itself ------------------------------------------------ */ const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS); t( 'the 2026-09-04 breach: ContentSecurityPolicy is dropped', Object.keys(stripped).sort(), [ 'ContentTypeOptions', 'FrameOptions', 'ReferrerPolicy', 'StrictTransportSecurity', 'XSSProtection', ], ); t( 'and five survive — the count docs/09 Part 3 tells the operator to read', Object.keys(stripped).length, 5, ); t( 'the surviving members are untouched', stripped.StrictTransportSecurity, LIVE_SECURITY_HEADERS.StrictTransportSecurity, ); t('nothing empty is left behind', emptyObjectPaths(stripped), []); /* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip turned into a hard abort (adversarial-reviewer, round 1) ------------- */ t( 'a top-level policy-config member is dropped', withoutEmptyMembers({ Name: 'p', CorsConfig: {}, SecurityHeadersConfig: stripped, }), { Name: 'p', SecurityHeadersConfig: stripped }, ); /* ---- and the one BELOW that, which the first repair still aborted on (adversarial-reviewer, round 2) -------------------------------------- */ t( 'a CorsConfig member is dropped, and the emptied CorsConfig with it', withoutEmptyMembers({ Name: 'p', CorsConfig: { AccessControlExposeHeaders: {} }, }), { Name: 'p' }, ); t( 'but a CorsConfig that still has content survives', withoutEmptyMembers({ CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false }, }), { CorsConfig: { OriginOverride: false } }, ); /* ---- things that must NOT be discarded ---------------------------------- */ t( 'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common', withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }), { RemoveHeadersConfig: { Quantity: 0, Items: [] } }, ); t( 'false, 0, null and empty string are kept', withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }), { a: false, b: 0, c: null, d: '' }, ); t( 'array elements are recursed into but never removed', withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }), { Items: [{ Header: 'X' }, {}] }, ); t( 'the custom-headers list the script builds is untouched', withoutEmptyMembers({ CustomHeadersConfig: { Quantity: 1, Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], }, }), { CustomHeadersConfig: { Quantity: 1, Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], }, }, ); t('stripping is idempotent', withoutEmptyMembers(stripped), stripped); /* ---- the drift comparison: {} and absent must normalise alike ------------ Round 1's repair stripped children but left `norm({})` as "{}" against `norm(undefined)` as "null", which reported permanent, unrepairable drift on the intake form's own path. */ const norm = (o) => { const v = withoutEmptyMembers(o); return JSON.stringify(isEmptyObject(v) ? null : (v ?? null)); }; t('norm({}) equals norm(undefined)', norm({}), norm(undefined)); t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({})); t( 'but a real difference still differs', norm({ a: 1 }) === norm({ a: 2 }), false, ); /* ---- emptyObjectPaths, the post-condition ------------------------------- */ t( 'reports the incident path', emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }), ['SecurityHeadersConfig.ContentSecurityPolicy'], ); t( 'reports round 2s deeper path', emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }), ['CorsConfig.AccessControlExposeHeaders'], ); t( 'reports an empty object inside an array, with its index', emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }), ['Items[1]'], ); t( 'reports every one, not just the first', emptyObjectPaths({ a: {}, b: { c: {} } }), ['a', 'b.c'], ); t('silent on an empty array', emptyObjectPaths({ a: [] }), []); t( 'silent on null, undefined and primitives', emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }), [], ); t('names the root when the whole config is empty', emptyObjectPaths({}), [ '(root)', ]); /* ---- the invariant the two functions exist to hold together ------------- */ t( 'THE INVARIANT: nothing survives the strip that the assertion would report', emptyObjectPaths( withoutEmptyMembers({ Name: 'adr-sml-pdf-noindex', SecurityHeadersConfig: LIVE_SECURITY_HEADERS, CorsConfig: { AccessControlExposeHeaders: {} }, ServerTimingHeadersConfig: {}, CustomHeadersConfig: { Quantity: 1, Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], }, }), ), [], ); /* ---- PAYLOAD_LIMITS: the 2026-09-04 second failure ----------------------- InvalidArgument, "The parameter Comment is too big", from create-origin-request-policy. The model types Comment as a bare `string`, so ParamValidation could not see it and the dry run was the only place it could have been caught. */ const ORP = (comment) => ({ Name: 'adr-sml-api-viewer-address', Comment: comment, HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['Origin'] }, }, }); t( 'the 182-character Comment that failed is reported', limitViolations('origin-request-policy', ORP('x'.repeat(182))).map((v) => [ v.path, v.actual, v.limit, ]), [['Comment', 182, 128]], ); t( 'the shipped Comment passes', limitViolations( 'origin-request-policy', ORP( 'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.', ), ), [], ); t( '128 exactly is allowed — the cap is inclusive', limitViolations('origin-request-policy', ORP('x'.repeat(128))), [], ); t( '129 is not', limitViolations('origin-request-policy', ORP('x'.repeat(129))).length, 1, ); t( 'the 118-character Comment AWS accepted on 2026-09-04 passes', limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex', Comment: 'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.', }), [], ); /* Section 4's payload needs its OWN over-cap cases: asserting only that the accepted 118 passes leaves the cap free to be wrong in the loose direction, which a mutation raising it to 1280 proved by surviving. */ t( 'a 129-character response-headers Comment is caught', limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex', Comment: 'x'.repeat(129), }).map((v) => [v.path, v.actual, v.limit]), [['Comment', 129, 128]], ); t( 'and 128 exactly is allowed', limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex', Comment: 'x'.repeat(128), }), [], ); t( 'an over-long policy Name is caught on both policy kinds', [ limitViolations('response-headers-policy', { Name: 'n'.repeat(129) }) .length, limitViolations('origin-request-policy', { Name: 'n'.repeat(129) }).length, ], [1, 1], ); t( 'and both shipped names pass', [ limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex' }) .length, limitViolations('origin-request-policy', { Name: 'adr-sml-api-viewer-address', }).length, ], [0, 0], ); /* ---- the function ARN, the one limit the service model does give us ------ */ const GOOD_ARN = 'arn:aws:cloudfront::327082975128:function/adr-sml-router'; t( 'a correctly derived function ARN passes', limitViolations('function-association', { FunctionARN: GOOD_ARN }), [], ); t( 'the tab-doubled ARN that `list-functions --output text` returns breaks both rules', limitViolations('function-association', { FunctionARN: `${GOOD_ARN}\t${GOOD_ARN}`, }) .map((v) => v.rule) .sort(), ['maxLength', 'pattern'], ); t( 'a Lambda@Edge ARN is not a CloudFront function ARN', limitViolations('function-association', { FunctionARN: 'arn:aws:lambda:us-east-1:327082975128:function:edge', }).some((v) => v.rule === 'pattern'), true, ); /* ---- the aggregate rules, both documented on the CloudFront quotas page -- */ const HDRS = (items) => ({ Name: 'adr-sml-api-viewer-address', Comment: 'c', HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: items.length, Items: items }, }, }); const SHIPPED_HEADERS = [ 'CloudFront-Viewer-Address', 'Content-Type', 'Origin', 'Referer', 'User-Agent', ]; t( 'the five headers we actually whitelist pass every rule', limitViolations('origin-request-policy', HDRS(SHIPPED_HEADERS)), [], ); t( 'an 11th whitelisted header breaches "Headers per origin request policy | 10"', limitViolations( 'origin-request-policy', HDRS(Array.from({ length: 11 }, (_, i) => `X-H${i}`)), ).map((v) => [v.rule, v.actual, v.limit]), [['maxCount', 11, 10]], ); t( 'ten is allowed', limitViolations( 'origin-request-policy', HDRS(Array.from({ length: 10 }, (_, i) => `X-H${i}`)), ), [], ); t( 'header names totalling over 1024 breach the combined-length quota', limitViolations( 'origin-request-policy', HDRS(Array.from({ length: 9 }, () => 'X'.repeat(120))), ) .map((v) => v.rule) .sort(), ['maxCombinedLength'], ); t( 'an 11th custom response header breaches its own count quota', limitViolations('response-headers-policy', { Name: 'n', CustomHeadersConfig: { Quantity: 11, Items: Array.from({ length: 11 }, (_, i) => ({ Header: `X-${i}`, Value: 'v', })), }, }).map((v) => v.rule), ['maxCount'], ); t( 'the single X-Robots-Tag header we add passes', limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex', Comment: 'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.', CustomHeadersConfig: { Quantity: 1, Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], }, }), [], ); t( 'names and values combined over 10,240 are caught', limitViolations('response-headers-policy', { Name: 'n', CustomHeadersConfig: { Quantity: 8, Items: Array.from({ length: 8 }, (_, i) => ({ Header: `X-${i}`, Value: 'v'.repeat(1500), })), }, }).some((v) => v.rule === 'maxCombinedLength'), true, ); /* ---- the walker -------------------------------------------------------- */ t( '[] resolves every element and the violation names the index', limitViolations('response-headers-policy', { Name: 'n', CustomHeadersConfig: { Quantity: 2, Items: [ { Header: 'X-Robots-Tag', Value: 'noindex' }, { Header: 'X'.repeat(300), Value: 'v' }, ], }, }).map((v) => v.path), ['CustomHeadersConfig.Items[1].Header'], ); t( 'an absent field is not a violation', limitViolations('response-headers-policy', { Name: 'n' }), [], ); t( 'a non-string value is skipped rather than crashing', limitViolations('response-headers-policy', { Name: 'n', Comment: 12345 }), [], ); t( 'a null along the path is skipped', limitViolations('response-headers-policy', { Name: 'n', CustomHeadersConfig: null, }), [], ); t( 'an unknown payload kind throws rather than passing silently', (() => { try { limitViolations('nope', {}); return 'no throw'; } catch (e) { return e.message.includes('nope'); } })(), true, ); t( 'every limit entry carries a source', Object.values(PAYLOAD_LIMITS) .flat() .every((l) => typeof l.source === 'string' && l.source.length > 0), true, ); t( 'every entry addresses exactly one of path / paths', Object.values(PAYLOAD_LIMITS) .flat() .every((l) => (l.path === undefined) !== (l.paths === undefined)), true, ); /* ---- the CLONED values, which change without this file being touched. `docs/05` specifies a Content-Security-Policy that would land on the default behaviour's policy and be copied straight into ours; the API declares TooLongCSPInResponseHeadersPolicy for exactly that. */ const withCsp = (csp) => ({ Name: 'adr-sml-pdf-noindex', Comment: 'c', SecurityHeadersConfig: { ContentTypeOptions: { Override: true }, ContentSecurityPolicy: { Override: false, ContentSecurityPolicy: csp }, }, }); t( 'a cloned CSP over 1,783 characters is caught before the create', limitViolations('response-headers-policy', withCsp('x'.repeat(1784))).map( (v) => [v.path, v.actual, v.limit], ), [ [ 'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy', 1784, 1783, ], ], ); t( '1,783 exactly is allowed', limitViolations('response-headers-policy', withCsp('x'.repeat(1783))), [], ); t( 'a realistic CSP passes', limitViolations( 'response-headers-policy', withCsp("default-src 'self'; img-src 'self' data:; style-src 'self'"), ), [], ); t( 'the live source policy, which defines no CSP at all, passes whole', limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex', Comment: 'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.', SecurityHeadersConfig: stripped, CustomHeadersConfig: { Quantity: 1, Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], }, }), [], ); if (failures.length) { console.error( `policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`, ); process.exit(1); } console.log(`policy-shapes: ${pass} of ${pass} cases pass`);