fix: omit AWS's empty-object placeholders from the cloned PDF policy
Build and deploy / build-and-deploy (push) Failing after 4s

`configure.mjs --apply` failed on its first write, 2026-09-04, and nothing
reached the distribution. `get-response-headers-policy` returns
`"ContentSecurityPolicy": {}` for a member the source does not define, and
sending that back fails `create-response-headers-policy` on ParamValidation
before the call leaves the machine — a config AWS hands back is not
necessarily a config AWS will accept.

Of the 16 structures reachable from `ResponseHeadersPolicyConfig` in the CLI's
service model, 15 declare a required field, so `{}` is illegal there and can
only be the placeholder; the one exception is `SecurityHeadersConfig` itself,
which section 4 already skips on when empty. The strip is therefore recursive.
The dry run now asserts the generated config carries no empty object, and does
so as a section-4 SKIP rather than a throw — section 4 must never block
sections 1-3 from re-applying `router.js`.

The two functions move to `policy-shapes.mjs` with a 23-case test (7 of 7
mutations killed), because `configure.mjs` reads argv and calls AWS at import
time and the runbook was otherwise claiming a proof nobody could re-run.

Also: the handler was redeployed 2026-09-04 via docs/09 §5.5. Re-read against
production — the two bundled SDK clients moved 3.1125.0 -> 3.1126.0 with no
file in this repository changing, which is what §7's own row predicted. §12
gains R22, because that row named itself as the reminder covering them while
no such reminder existed. docs/05, docs/06 and docs/09 §5.5 each held their
own stale copy of the deployed commit; all three now cite §7.

Reviewed twice by adversarial-reviewer: 7 findings, then 8, of which five were
defects in the first round's repairs. All 15 fixed.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-04 12:59:04 -04:00
co-authored by Claude Opus 5
parent 3c3ba5dc6e
commit bbe535d158
7 changed files with 802 additions and 182 deletions
+240 -167
View File
@@ -41,6 +41,14 @@
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
/* Section 4's clone, and why an empty member is always AWS's placeholder
rather than a value: `policy-shapes.mjs`. Tested there, because this file
reads argv and calls AWS at import time. */
import {
withoutEmptyMembers,
isEmptyObject,
emptyObjectPaths,
} from './policy-shapes.mjs';
const args = process.argv.slice(2);
const flag = (name) => {
@@ -383,18 +391,49 @@ if (!defaultRhpId) {
instead, naming the diff. */
const source = getResponseHeadersPolicy(defaultRhpId);
const srcCfg = source?.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig;
const clonedShc = withoutEmptyMembers(srcCfg?.SecurityHeadersConfig);
const undefinedMembers = Object.keys(
srcCfg?.SecurityHeadersConfig ?? {},
).filter((k) => !(k in clonedShc));
/* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was
inconsistent for one round. A policy carrying only `CorsConfig` is legal;
an ABSENT source is section 4's problem alone and must not stop sections
1-3 from re-applying `router.js`. The DRIFT throw below is different: that
is a divergence, not an absence, and `docs/09` Part 3 argues for it. */
1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE
EXCEPTIONS, and they are exceptions for the same reason: DRIFT is a
divergence rather than an absence (`docs/09` Part 3 argues for it), and the
EMPTY-OBJECT throw means this script generated an invalid config — a bug in
the script, not a state of the world. Neither is something a later section
should be allowed to paper over. */
if (!srcCfg?.SecurityHeadersConfig) {
skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
);
} else if (Object.keys(clonedShc).length === 0) {
/* Distinct from the branch above, and the distinction is the operator's:
ABSENT means the source policy is a different kind of thing, while
ALL-EMPTY means it is the right kind and defines nothing. Both leave the
PDF without security headers; only the second would read as a bug. */
skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has a SecurityHeadersConfig that defines nothing` +
(undefinedMembers.length
? ` (all ${undefinedMembers.length} of its members are empty: ${undefinedMembers.join(', ')})`
: ' — it is an empty object') +
`, so there are no security headers to clone`,
);
} else {
const wanted = {
SecurityHeadersConfig: srcCfg.SecurityHeadersConfig,
/* ⚠️ UNCONDITIONAL. This was gated on `undefinedMembers.length`, so on a
source that defines all six the line DISAPPEARED rather than reading six
— and `docs/09` Part 3 tells the operator to read this number as the
count of headers the PDF will carry. An absent line is not a smaller
number; it is nothing to compare (`adversarial-reviewer`). */
console.log(
`· cloning ${Object.keys(clonedShc).length} defined security header(s)` +
(undefinedMembers.length
? `; omitting ${undefinedMembers.length} the source does not define (${undefinedMembers.join(', ')})`
: ''),
);
const wanted = withoutEmptyMembers({
SecurityHeadersConfig: clonedShc,
...(srcCfg.CorsConfig ? { CorsConfig: srcCfg.CorsConfig } : {}),
...(srcCfg.RemoveHeadersConfig
? { RemoveHeadersConfig: srcCfg.RemoveHeadersConfig }
@@ -406,176 +445,210 @@ if (!defaultRhpId) {
Quantity: (srcCfg.CustomHeadersConfig?.Items ?? []).length + 1,
Items: [...(srcCfg.CustomHeadersConfig?.Items ?? []), XRT],
},
};
});
if (existingPdfPolicy) {
const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
const norm = (o) => JSON.stringify(o ?? null);
const drift = [
'SecurityHeadersConfig',
'CorsConfig',
'RemoveHeadersConfig',
'ServerTimingHeadersConfig',
]
.filter((k) => norm(have[k]) !== norm(wanted[k]))
.concat(
norm(have.CustomHeadersConfig?.Items) !==
norm(wanted.CustomHeadersConfig.Items)
? ['CustomHeadersConfig']
: [],
);
if (drift.length) {
/* Print BOTH SIDES of every drifted key. Naming the field alone does not
tell the operator which header moved, nor which direction to reconcile
in — the same message fires whether the source gained a header or the
PDF policy lost its X-Robots-Tag, and those need opposite repairs. */
const detail = drift
.map(
(k) =>
` ${k}\n pdf policy : ${norm(
k === 'CustomHeadersConfig'
? have.CustomHeadersConfig?.Items
: have[k],
)}\n default : ${norm(
k === 'CustomHeadersConfig'
? wanted.CustomHeadersConfig.Items
: wanted[k],
)}`,
)
.join('\n');
throw new Error(
`${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` +
`${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` +
`different headers from the pages — read which way before repairing:\n` +
`${detail}\n` +
`Reconcile with update-response-headers-policy (it needs the policy's ` +
`own ETag), then re-run. This script will not silently paper over it.`,
);
}
console.log(
`· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`,
);
} else if (!APPLY) {
console.log(`· would CREATE response-headers policy ${PDF_POLICY_NAME}`);
changes.push(
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
/* ⚠️ ASSERT BEFORE ANY BRANCH, SO THE DRY RUN CARRIES IT TOO — Pouya's
ruling of 2026-09-04. The clone that failed was syntactically fine and
printed a clean dry run; a check that runs only on the writing path
reports the class after it has already cost the run.
⚠️ A SKIP, NOT A THROW — it was a throw for one round. An empty object
here is section 4's problem alone, and this file's contract is that
section 4 never blocks sections 1-3 from re-applying `router.js`, without
which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it
prints under its own heading and the NOTHING TO CHANGE guard names it. */
const empties = emptyObjectPaths(wanted);
if (empties.length) {
skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` +
`contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` +
`AWS rejects those on ParamValidation before the call leaves the machine. ` +
`withoutEmptyMembers should have removed them, so either it is broken or ` +
`this script built one itself — see policy-shapes.mjs and its test.`,
);
} else {
const created = aws([
'cloudfront',
'create-response-headers-policy',
'--response-headers-policy-config',
JSON.stringify({
Name: PDF_POLICY_NAME,
Comment:
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
...wanted,
}),
'--output',
'json',
]);
pdfPolicyId = created?.ResponseHeadersPolicy?.Id;
if (!pdfPolicyId) {
throw new Error('create-response-headers-policy returned no Id');
}
console.log(
`created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`,
);
changes.push(
`created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`,
);
}
const pdfBehaviours = cfg.CacheBehaviors?.Items ?? [];
const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN);
if (foundPdf) {
/* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf`
behaviour existed, so one added by hand — while chasing the
`aws s3 sync --metadata` route this file's header records as the original
instruction — would report `already present`, push nothing, and print
NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1
compares the FunctionARN before declaring a match; so does this now. */
const wrong = [];
if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) {
wrong.push(
`ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`,
);
}
if (foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId) {
wrong.push(
`TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`,
);
}
const hasViewerRequest = (
foundPdf.FunctionAssociations?.Items ?? []
).some((i) => i.EventType === 'viewer-request');
if (!hasViewerRequest) {
wrong.push(
'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301',
);
}
if (wrong.length) {
throw new Error(
`a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` +
`script manages:\n - ${wrong.join('\n - ')}\n` +
`Reconcile or remove it before re-running; this script will not adopt ` +
`a behaviour it cannot account for.`,
);
}
console.log(
`· cache behaviour ${PDF_PATTERN} already present and correctly configured`,
);
} else {
const d = cfg.DefaultCacheBehavior;
const behaviour = {
PathPattern: PDF_PATTERN,
TargetOriginId: d.TargetOriginId,
ViewerProtocolPolicy: d.ViewerProtocolPolicy,
AllowedMethods: d.AllowedMethods,
CachePolicyId: d.CachePolicyId,
/* Placeholder only in a dry run — the real id exists by the time --apply
reaches this line, because the branch above created it. */
ResponseHeadersPolicyId: pdfPolicyId ?? '<created on --apply>',
Compress: d.Compress,
SmoothStreaming: false,
FieldLevelEncryptionId: '',
/* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS.
`router.js` normalises `\` to `/` and collapses a leading `//` run
BEFORE it tests for an extension, and 301s when normalisation changed
anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical
path today. Omitting the association here would silently drop that and
hand S3 the doubled key instead. The `/api/*` reason for omitting it —
a 301 turning a POST into a GET and losing the body — does not apply to
a GET-only PDF. */
FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 },
LambdaFunctionAssociations: { Quantity: 0 },
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
};
/* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole
job is to show what would touch a distribution serving 23 pages; reporting
only the harmless policy creation and staying silent about the behaviour
would mean the first sight of it is `update-distribution` writing it. The
`cfg` mutation stays gated on a real id; the REPORT does not. */
changes.push(
`CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` +
(pdfPolicyId
? ` (${pdfPolicyId})`
: ' (policy id created in the same --apply pass)'),
);
if (!APPLY && !pdfPolicyId) {
if (existingPdfPolicy) {
const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
/* ⚠️ `{}` AND ABSENT MUST NORMALISE ALIKE, ON BOTH SIDES. `wanted` has
had AWS's placeholders stripped; the live policy may still echo them.
Stripping alone is not enough — that leaves `norm({})` as `"{}"`
against `norm(undefined)` as `"null"`, which reports drift that no
`update-response-headers-policy` can clear, on the intake form's own
path. An empty member is never a real divergence. */
const norm = (o) => {
const v = withoutEmptyMembers(o);
return JSON.stringify(isEmptyObject(v) ? null : (v ?? null));
};
const drift = [
'SecurityHeadersConfig',
'CorsConfig',
'RemoveHeadersConfig',
'ServerTimingHeadersConfig',
]
.filter((k) => norm(have[k]) !== norm(wanted[k]))
.concat(
norm(have.CustomHeadersConfig?.Items) !==
norm(wanted.CustomHeadersConfig.Items)
? ['CustomHeadersConfig']
: [],
);
if (drift.length) {
/* Print BOTH SIDES of every drifted key. Naming the field alone does not
tell the operator which header moved, nor which direction to reconcile
in — the same message fires whether the source gained a header or the
PDF policy lost its X-Robots-Tag, and those need opposite repairs. */
const detail = drift
.map(
(k) =>
` ${k}\n pdf policy : ${norm(
k === 'CustomHeadersConfig'
? have.CustomHeadersConfig?.Items
: have[k],
)}\n default : ${norm(
k === 'CustomHeadersConfig'
? wanted.CustomHeadersConfig.Items
: wanted[k],
)}`,
)
.join('\n');
throw new Error(
`${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` +
`${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` +
`different headers from the pages — read which way before repairing:\n` +
`${detail}\n` +
`Reconcile with update-response-headers-policy (it needs the policy's ` +
`own ETag), then re-run. This script will not silently paper over it.`,
);
}
console.log(
`· would ADD cache behaviour ${PDF_PATTERN}:\n` +
JSON.stringify(behaviour, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n'),
`· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`,
);
} else if (!APPLY) {
console.log(
`· would CREATE response-headers policy ${PDF_POLICY_NAME}`,
);
changes.push(
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
);
} else {
pdfBehaviours.push(behaviour);
cfg.CacheBehaviors = {
Quantity: pdfBehaviours.length,
Items: pdfBehaviours,
const created = aws([
'cloudfront',
'create-response-headers-policy',
'--response-headers-policy-config',
JSON.stringify({
Name: PDF_POLICY_NAME,
Comment:
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
...wanted,
}),
'--output',
'json',
]);
pdfPolicyId = created?.ResponseHeadersPolicy?.Id;
if (!pdfPolicyId) {
throw new Error('create-response-headers-policy returned no Id');
}
console.log(
`created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`,
);
changes.push(
`created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`,
);
}
const pdfBehaviours = cfg.CacheBehaviors?.Items ?? [];
const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN);
if (foundPdf) {
/* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf`
behaviour existed, so one added by hand — while chasing the
`aws s3 sync --metadata` route this file's header records as the original
instruction — would report `already present`, push nothing, and print
NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1
compares the FunctionARN before declaring a match; so does this now. */
const wrong = [];
if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) {
wrong.push(
`ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`,
);
}
if (
foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId
) {
wrong.push(
`TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`,
);
}
const hasViewerRequest = (
foundPdf.FunctionAssociations?.Items ?? []
).some((i) => i.EventType === 'viewer-request');
if (!hasViewerRequest) {
wrong.push(
'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301',
);
}
if (wrong.length) {
throw new Error(
`a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` +
`script manages:\n - ${wrong.join('\n - ')}\n` +
`Reconcile or remove it before re-running; this script will not adopt ` +
`a behaviour it cannot account for.`,
);
}
console.log(
`· cache behaviour ${PDF_PATTERN} already present and correctly configured`,
);
} else {
const d = cfg.DefaultCacheBehavior;
const behaviour = {
PathPattern: PDF_PATTERN,
TargetOriginId: d.TargetOriginId,
ViewerProtocolPolicy: d.ViewerProtocolPolicy,
AllowedMethods: d.AllowedMethods,
CachePolicyId: d.CachePolicyId,
/* Placeholder only in a dry run — the real id exists by the time --apply
reaches this line, because the branch above created it. */
ResponseHeadersPolicyId: pdfPolicyId ?? '<created on --apply>',
Compress: d.Compress,
SmoothStreaming: false,
FieldLevelEncryptionId: '',
/* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS.
`router.js` normalises `\` to `/` and collapses a leading `//` run
BEFORE it tests for an extension, and 301s when normalisation changed
anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical
path today. Omitting the association here would silently drop that and
hand S3 the doubled key instead. The `/api/*` reason for omitting it —
a 301 turning a POST into a GET and losing the body — does not apply to
a GET-only PDF. */
FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 },
LambdaFunctionAssociations: { Quantity: 0 },
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
};
/* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole
job is to show what would touch a distribution serving 23 pages; reporting
only the harmless policy creation and staying silent about the behaviour
would mean the first sight of it is `update-distribution` writing it. The
`cfg` mutation stays gated on a real id; the REPORT does not. */
changes.push(
`CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` +
(pdfPolicyId
? ` (${pdfPolicyId})`
: ' (policy id created in the same --apply pass)'),
);
if (!APPLY && !pdfPolicyId) {
console.log(
`· would ADD cache behaviour ${PDF_PATTERN}:\n` +
JSON.stringify(behaviour, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n'),
);
} else {
pdfBehaviours.push(behaviour);
cfg.CacheBehaviors = {
Quantity: pdfBehaviours.length,
Items: pdfBehaviours,
};
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Shape helpers for the CloudFront policy configs `configure.mjs` builds.
*
* ⚠️ **A POLICY AWS HANDS BACK IS NOT A POLICY AWS WILL ACCEPT.**
* `get-response-headers-policy` returns `{}` for a member the source does not
* define — `Managed-SecurityHeadersPolicy` does it for `ContentSecurityPolicy`
* — and sending that back fails `create-response-headers-policy` on
* ParamValidation before the call leaves the machine. `docs/09` Part 3 carries
* the incident and the exact error.
*
* **Dropping an empty member is safe at every depth, and that is a measurement
* rather than a hope.** Of the 16 structures reachable from
* `ResponseHeadersPolicyConfig` in the CLI's own service model, **15 declare at
* least one required field** — so `{}` is not a legal value there and can only
* be the placeholder. The single exception is `SecurityHeadersConfig` itself,
* and `configure.mjs` skips before it can build one of those empty, because a
* PDF policy cloning no security headers is the thing that section exists to
* avoid.
*
* They live in their own module so they can be tested: `configure.mjs` reads
* argv and calls AWS at import time, so importing THAT to reach two pure
* functions is not possible. Same reason `fields.mjs` sits beside
* `handler.mjs`. See `policy-shapes.test.mjs`.
*/
/**
* Every empty-object member removed, at every depth, bottom-up — so a member
* left empty by stripping its own children is removed in turn.
*
* Arrays are recursed into but never have elements removed: an element index is
* load-bearing against its `Quantity` sibling, and an empty object inside one
* would be this script's own construction rather than an AWS placeholder. That
* case is left for `emptyObjectPaths` to report.
*/
export const withoutEmptyMembers = (value) => {
if (Array.isArray(value)) return value.map(withoutEmptyMembers);
if (!value || typeof value !== 'object') return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
const cleaned = withoutEmptyMembers(v);
const isEmptyObject =
cleaned &&
typeof cleaned === 'object' &&
!Array.isArray(cleaned) &&
Object.keys(cleaned).length === 0;
if (!isEmptyObject) out[k] = cleaned;
}
return out;
};
/** True for `{}` — the value AWS accepts nowhere in these configs. */
export const isEmptyObject = (v) =>
Boolean(v) &&
typeof v === 'object' &&
!Array.isArray(v) &&
Object.keys(v).length === 0;
/**
* The dotted path of every empty object left in a config. A post-condition on
* the strip above, not a filter: if this returns anything, the strip did not do
* what this module claims it does.
*
* Empty ARRAYS are not reported — `{Quantity: 0, Items: []}` is valid and
* common, while an empty object is valid nowhere.
*/
export function emptyObjectPaths(value, path = '') {
if (Array.isArray(value)) {
return value.flatMap((v, i) => emptyObjectPaths(v, `${path}[${i}]`));
}
if (value && typeof value === 'object') {
if (Object.keys(value).length === 0) return [path || '(root)'];
return Object.entries(value).flatMap(([k, v]) =>
emptyObjectPaths(v, path ? `${path}.${k}` : k),
);
}
return [];
}
+204
View File
@@ -0,0 +1,204 @@
/**
* 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,
} 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 }],
},
}),
),
[],
);
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`);