Skip to content

Commit 9d03026

Browse files
committed
fix(s3): correct the would-have-caught claim, harden the strip-probes, and guard a null EventBridge block
Review fix-back for #1437. The headline correction: this target's central claim was FALSE, and the tests that were supposed to prove it were passing for the wrong reason. WHAT THE CRITIC WOULD ACTUALLY HAVE CAUGHT. Measured by running it against the REAL pre-#1426 provider (`git show 768cd44^`) rather than predicted: it flags `Transition`, `NoncurrentVersionTransition` and `NoncurrentVersionExpirationInDays` -- the legacy-singular defect. Issue #1430 predicted `TagFilters`, `NoncurrentVersionTransitions[].TransitionInDays` and rule-level `ExpiredObjectDeleteMarker`, and was wrong on all three: - `TagFilters` (16 literal sites pre-#1426) and `TransitionInDays` (2) are also named by `readCurrentState`'s reverse map, so the file-global literal heuristic reports `provider-handled` however broken the write path is. That is item 2 of #1393. - `ExpiredObjectDeleteMarker` is not shape-audited at all: the shape pass matches CFn definitions to same-named SDK interfaces, and `@aws-sdk/client-s3` spells it `LifecycleRule`, so CFn's `Rule` sits in `unmatchedDefinitions` and the whole lifecycle-rule blob is unaudited. The strip-probes inherited the wrong list, and `replaceAll` hid it by deleting EVERY occurrence -- a regression shape that cannot occur. They now probe only keys a realistic single-site regression can actually silence, and each probe ASSERTS the key left `collectStringLiterals`'s evidence set before asserting the bucket. That self-validation immediately caught a second miss: the read-side fix makes `EventBridgeEnabled` an object-literal property name as well as a quoted literal, so stripping only `'EventBridgeEnabled'` left it in evidence. A new test pins the honest limit -- removing ONLY TagFilters' write-side conversion must still classify `provider-handled` -- so nobody re-adds the false claim. Provider fixes: - `EventBridgeConfiguration: null` threw. The branch now reads a member off the block, so an explicit null (hand-written JSON, or an intrinsic resolving to null) hit `Cannot read properties of null` where the pre-change `eb !== undefined` test merely emitted. Guarded with the file's existing `isPlainObject`; non-objects stay on the enable-on-presence side. - `coerceCfnBoolean` is now case-insensitive. This is the one call site where "not false" means "turn it on", so `'False'` falling through to `undefined` would silently ENABLE delivery -- the exact inversion this PR fixes. - The `readCurrentState` comment claimed the always-emit is invisible to a state record with no `EventBridgeConfiguration` key. Not true: `cdkd drift` uses `unionWalkObjects: true`, so upgrading reports ONE cosmetic diff per bucket until the next `state refresh-observed` / `drift --accept` / real UPDATE. The comment now says so, matching the convention in ec2 / ssm / s3-tables. Tests: +6 units (case variants, null block, EventBridge-false alongside a Topic config, and the true -> false UPDATE flip, which no existing test covered since they all start from a bare previous state). The round-trip test was vacuous -- with the read fix reverted the reader returns `{}`, which the write path also emits as `{}` -- so it is split, and the DISCRIMINATING direction is now covered: the disabled shape fed back through the write path must NOT re-enable delivery, which is what a `drift --revert` would do. The `#1378` definitionShapes fence sorts before picking its stand-in fixture (`readdirSync` order differs between macOS and CI) and requires a string `resourceType`, so it cannot pick the bookkeeping file and throw a TypeError instead of the assertion it means to make. Integ: the EventBridge + drift assertions are factored into functions and run after BOTH phases, so the `diffSubConfig` -> `applyNotificationConfiguration` UPDATE call site is covered against real AWS too. Re-run: PASS, destroy 3 deleted / 0 errors / 0 orphans. Also corrects "the largest target" (CloudFront Distribution is 121 keys, S3 is 115) in `.claude/rules/code-layout.md`.
1 parent 5ec26e5 commit 9d03026

8 files changed

Lines changed: 316 additions & 78 deletions

File tree

.claude/rules/code-layout.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ s3-asset-deploy 2026-07-26T19:45:58Z PASS 58 verify.sh 0727b sweep-b12 staleness
230230
s3-cloudfront 2026-08-08T17:42:05Z PASS 228 verify.sh 1373 OriginCustomHeaders create+update-survive asserts green; destroy 0 errors 0 orphans
231231
s3-directory-bucket 2026-08-02T15:38:47Z PASS 50 verify.sh re-run for #1347 wontdo-comment PR; guard + clean destroy, 0 orphans
232232
s3-event-notification 2026-07-21T05:28:05Z PASS 79 verify.sh rc ok, orph clean
233-
s3-lifecycle 2026-08-09T15:54:29Z PASS 150 verify.sh #1430 EventBridgeEnabled true/false + clean-drift asserts added; destroy 0 err, 0 orph
233+
s3-lifecycle 2026-08-09T16:35:24Z PASS 84 verify.sh #1430 EventBridge pair + drift asserted after BOTH phases; destroy 0 err, 0 orph
234234
s3-object-lock 2026-07-21T14:42:20Z PASS 53 verify.sh rc ok, orph clean
235235
s3-replication-and-filter 2026-07-21T14:43:45Z PASS 63 verify.sh rc ok, orph clean
236236
s3-tables 2026-07-27T16:53:47Z PASS 45 verify.sh issue #1270/#1272 post-review re-run; 5 del 0 err, 0 orphans

docs/changelog-cdkd.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

scripts/gen-nested-key-coverage.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,11 +237,28 @@ export const NESTED_KEY_TARGETS: readonly NestedKeyTarget[] = [
237237
// inventory, analytics, metrics, intelligent-tiering, object-lock,
238238
// website routing) in `handledProperties` and re-shapes each for the SDK —
239239
// the forwarding shape this critic exists to audit. It was NOT a target
240-
// when the #1388 / #1424 lifecycle defects were fixed by hand in PR #1426,
241-
// and three of those six would have been caught mechanically here
242-
// (`TagFilters` + `NoncurrentVersionTransitions[].TransitionInDays` as
243-
// `no-sdk-member` on the key pass, rule-level `ExpiredObjectDeleteMarker`
244-
// as `definition-member-missing` on the shape pass).
240+
// when the #1388 / #1424 lifecycle defects were fixed by hand in PR #1426.
241+
//
242+
// WHAT THIS TARGET WOULD ACTUALLY HAVE CAUGHT, measured rather than
243+
// predicted: running this critic against the REAL pre-#1426 provider
244+
// (`git show 768cd44b^:…/s3-bucket-provider.ts`) flags exactly three
245+
// lifecycle keys as `no-sdk-member` — `Transition`,
246+
// `NoncurrentVersionTransition` and `NoncurrentVersionExpirationInDays`,
247+
// i.e. the LEGACY SINGULAR FORMS defect.
248+
//
249+
// Issue #1430 predicted a different three, and it was wrong on all of
250+
// them. `TagFilters` (16 literal occurrences pre-#1426) and
251+
// `TransitionInDays` (2) were already named elsewhere in the file — by
252+
// `readCurrentState`'s reverse map — so the file-global literal heuristic
253+
// classifies both `provider-handled` no matter how broken the WRITE path
254+
// is. That is exactly the blind spot tracked as item 2 of #1393, and it is
255+
// why the strip-probes in the unit test use only single-occurrence keys: a
256+
// probe that deletes EVERY occurrence of a 16-site literal tests a
257+
// regression shape that cannot occur. Rule-level `ExpiredObjectDeleteMarker`
258+
// is not reachable either — the shape pass matches CFn definitions to
259+
// same-named SDK interfaces, and `@aws-sdk/client-s3` spells it
260+
// `LifecycleRule`, so CFn's `Rule` sits in `unmatchedDefinitions` and the
261+
// whole lifecycle-rule blob is shape-unaudited.
245262
resourceType: 'AWS::S3::Bucket',
246263
providerFile: 's3-bucket-provider.ts',
247264
sdkClientPackage: '@aws-sdk/client-s3',

src/provisioning/providers/s3-bucket-provider.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,16 @@ function mergeLegacySingular(
121121
/** Coerce a CFn boolean, which may arrive as the string `"true"` / `"false"`. */
122122
function coerceCfnBoolean(value: unknown): boolean | undefined {
123123
if (typeof value === 'boolean') return value;
124-
if (value === 'true') return true;
125-
if (value === 'false') return false;
124+
// Case-insensitive on purpose. CDK renders lowercase, but this also feeds
125+
// `NotificationConfiguration.EventBridgeConfiguration` (issue #1430), where
126+
// "not false" means "enable" — so a hand-written / imported `'False'` that
127+
// fell through to `undefined` would silently ENABLE EventBridge delivery,
128+
// the exact inversion #1430 fixed.
129+
if (typeof value === 'string') {
130+
const lowered = value.toLowerCase();
131+
if (lowered === 'true') return true;
132+
if (lowered === 'false') return false;
133+
}
126134
return undefined;
127135
}
128136

@@ -873,8 +881,15 @@ export class S3BucketProvider implements ResourceProvider {
873881
Filter: this.cfnNotifFilterToSdk(l['Filter']),
874882
}));
875883
}
876-
const eb = notifConfig['EventBridgeConfiguration'] as Record<string, unknown> | undefined;
877-
if (eb !== undefined && coerceCfnBoolean(eb['EventBridgeEnabled']) !== false) {
884+
const eb = notifConfig['EventBridgeConfiguration'];
885+
// `isPlainObject`, not `!== undefined`: this branch now READS a member off
886+
// the block, so an explicit `null` (hand-written JSON / an intrinsic that
887+
// resolved to null) would throw where it previously just emitted the
888+
// block. A non-object stays on the pre-change enable-on-presence side.
889+
if (
890+
eb !== undefined &&
891+
(!isPlainObject(eb) || coerceCfnBoolean(eb['EventBridgeEnabled']) !== false)
892+
) {
878893
// The SDK's `EventBridgeConfiguration` is an EMPTY structure — presence
879894
// enables EventBridge delivery, absence disables it. CFn instead carries
880895
// a REQUIRED boolean `EventBridgeEnabled` inside the block (that is what
@@ -2352,11 +2367,22 @@ export class S3BucketProvider implements ResourceProvider {
23522367
}
23532368
// Always-emit, and in the CFn shape (`{EventBridgeEnabled: <bool>}`) rather
23542369
// than the SDK's empty-structure shape — cdkd's state baseline holds the
2355-
// CFn spelling, and `drift-calculator` descends only into keys present in
2356-
// state, so returning `{}` reported the boolean as permanently missing on
2357-
// every EventBridge-enabled bucket (issue #1430). Emitting `false` when the
2358-
// response omits the block keeps the disabled side comparable too; a state
2359-
// record with no `EventBridgeConfiguration` key is unaffected either way.
2370+
// CFn spelling, so returning `{}` reported the boolean as permanently
2371+
// missing on every EventBridge-enabled bucket (issue #1430). Emitting
2372+
// `false` when the response omits the block keeps the disabled side
2373+
// comparable too.
2374+
//
2375+
// ONE-TIME DRIFT ON UPGRADE, accepted deliberately. `cdkd drift` runs the
2376+
// observed-properties path with `unionWalkObjects: true`, which walks the
2377+
// union of baseline+AWS keys inside a nested object, so a state record
2378+
// captured by an older binary reports one diff on the first run after
2379+
// upgrading: `EventBridgeConfiguration: {}` vs `{EventBridgeEnabled: true}`
2380+
// on an enabled bucket, or an added `EventBridgeConfiguration` on a bucket
2381+
// with no such key at all. It is cosmetic — `--revert` re-sends the old
2382+
// observed blob and the write side re-derives the same AWS state — and it
2383+
// clears on the next `cdkd state refresh-observed`, `drift --accept`, or
2384+
// real UPDATE of the bucket. The alternative (emit only when present) is
2385+
// what caused the PERMANENT phantom drift this fixes.
23602386
out['EventBridgeConfiguration'] = {
23612387
EventBridgeEnabled: resp.EventBridgeConfiguration !== undefined,
23622388
};

tests/integration/s3-lifecycle/verify.sh

Lines changed: 65 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
# Phases:
1414
# 1. Deploy; assert all three rules reached AWS, none carries a top-level Prefix
1515
# (all normalized to V2 Filter form), and the archive rule's expiration=730.
16+
# Also assert the legacy singular lifecycle keys (issue #1388 / #1424) and
17+
# the issue #1430 EventBridgeEnabled pair (true -> block present, false ->
18+
# block absent, matching CloudFormation), then `cdkd drift` clean.
1619
# 2. Re-deploy with CDKD_TEST_UPDATE=true (expiration 730 -> 365, GLACIER
1720
# transition 90 -> 60, + a new big-objects Filter rule). Assert the new
1821
# values reached AWS, there are 4 rules, and the bucket was NOT replaced.
19-
# 3. Destroy; assert the bucket is gone and the state file is removed.
22+
# Re-assert the EventBridge pair + drift, so the UPDATE path is covered too.
23+
# 3. Destroy; assert all three buckets are gone and the state file is removed.
2024
#
2125
# Required env vars:
2226
# STATE_BUCKET — cdkd state bucket (e.g. cdkd-state-{accountId})
@@ -216,53 +220,66 @@ echo " legacy singular Transition + NoncurrentVersionTransition + NoncurrentV
216220
# ENABLED notifications.
217221
#
218222
# Expected values are CloudFormation ground truth, not a guess: a real CFn A/B
219-
# of this exact shape (stack Cdkd1430EbProbe, us-east-1, 2026-08-10) returned
220-
# an EMPTY body for `false` and `{"EventBridgeConfiguration": {}}` for `true`.
223+
# of this exact shape (stack Cdkd1430EbProbe, us-east-1, 2026-08-10) gave an
224+
# EMPTY response for `false` and `{"EventBridgeConfiguration": {}}` for `true`.
221225
#
222-
# An unconfigured bucket returns an EMPTY body, not `{}`, so both captures are
223-
# normalized before jq sees them -- without that the `false` assertion fails on
224-
# cdkd's CORRECT output. (The first real run of this assertion did exactly
225-
# that.) The captures are unguarded on purpose: `set -e` aborts on a genuine
226-
# API failure, so reaching the normalization with an empty string means a
227-
# successful call on an unconfigured bucket.
228-
EB_TRUE_JSON="$(aws s3api get-bucket-notification-configuration \
229-
--bucket "${EB_TRUE_BUCKET}" --region "${REGION}" --output json)"
230-
EB_FALSE_JSON="$(aws s3api get-bucket-notification-configuration \
231-
--bucket "${LEGACY_BUCKET}" --region "${REGION}" --output json)"
232-
[ -n "${EB_TRUE_JSON//[[:space:]]/}" ] || EB_TRUE_JSON='{}'
233-
[ -n "${EB_FALSE_JSON//[[:space:]]/}" ] || EB_FALSE_JSON='{}'
234-
235-
EB_TRUE_HAS="$(printf '%s' "${EB_TRUE_JSON}" | jq -r 'has("EventBridgeConfiguration")')"
236-
EB_FALSE_HAS="$(printf '%s' "${EB_FALSE_JSON}" | jq -r 'has("EventBridgeConfiguration")')"
237-
238-
# The `true` side runs FIRST and is the vacuity guard: asserting only that the
239-
# `false` bucket lacks the block would pass just as happily if cdkd stopped
240-
# applying NotificationConfiguration altogether.
241-
if [ "${EB_TRUE_HAS}" != "true" ]; then
242-
echo "FAIL: ${EB_TRUE_BUCKET} (EventBridgeEnabled: true) has NO EventBridgeConfiguration" >&2
243-
echo " response: ${EB_TRUE_JSON}" >&2
244-
exit 1
245-
fi
246-
if [ "${EB_FALSE_HAS}" != "false" ]; then
247-
echo "FAIL: ${LEGACY_BUCKET} (EventBridgeEnabled: false) HAS an EventBridgeConfiguration" >&2
248-
echo " this is the issue #1430 inversion: an explicit false enabled delivery" >&2
249-
echo " response: ${EB_FALSE_JSON}" >&2
250-
exit 1
251-
fi
252-
echo " EventBridgeEnabled true -> block present, false -> block absent (matches CloudFormation)"
226+
# Run after BOTH phases: Phase 1 covers create(), Phase 2 covers the
227+
# diffSubConfig -> applyNotificationConfiguration UPDATE path, which is a
228+
# different call site and was previously unexercised against real AWS.
229+
assert_eventbridge_pair() { # $1 = phase label
230+
local phase="$1"
231+
# An unconfigured bucket returns an EMPTY body, not `{}`, so both captures
232+
# are normalized before jq sees them -- without that the `false` assertion
233+
# fails on cdkd's CORRECT output. (The first real run of this assertion did
234+
# exactly that.) The captures are unguarded on purpose: `set -e` aborts on a
235+
# genuine API failure, so reaching the normalization with an empty string
236+
# means a successful call on an unconfigured bucket.
237+
local eb_true_json eb_false_json eb_true_has eb_false_has
238+
eb_true_json="$(aws s3api get-bucket-notification-configuration \
239+
--bucket "${EB_TRUE_BUCKET}" --region "${REGION}" --output json)" || return 1
240+
eb_false_json="$(aws s3api get-bucket-notification-configuration \
241+
--bucket "${LEGACY_BUCKET}" --region "${REGION}" --output json)" || return 1
242+
[ -n "${eb_true_json//[[:space:]]/}" ] || eb_true_json='{}'
243+
[ -n "${eb_false_json//[[:space:]]/}" ] || eb_false_json='{}'
244+
245+
eb_true_has="$(printf '%s' "${eb_true_json}" | jq -r 'has("EventBridgeConfiguration")')" || return 1
246+
eb_false_has="$(printf '%s' "${eb_false_json}" | jq -r 'has("EventBridgeConfiguration")')" || return 1
247+
248+
# The `true` side is the vacuity guard: asserting only that the `false`
249+
# bucket lacks the block would pass just as happily if cdkd stopped applying
250+
# NotificationConfiguration altogether. Both assertions run unconditionally;
251+
# it is the PRESENCE of the true-side check that is load-bearing, not the
252+
# order in which they appear.
253+
if [ "${eb_true_has}" != "true" ]; then
254+
echo "FAIL [${phase}]: ${EB_TRUE_BUCKET} (EventBridgeEnabled: true) has NO EventBridgeConfiguration" >&2
255+
echo " response: ${eb_true_json}" >&2
256+
exit 1
257+
fi
258+
if [ "${eb_false_has}" != "false" ]; then
259+
echo "FAIL [${phase}]: ${LEGACY_BUCKET} (EventBridgeEnabled: false) HAS an EventBridgeConfiguration" >&2
260+
echo " this is the issue #1430 inversion: an explicit false enabled delivery" >&2
261+
echo " response: ${eb_false_json}" >&2
262+
exit 1
263+
fi
264+
echo " [${phase}] EventBridgeEnabled true -> block present, false -> block absent (matches CloudFormation)"
265+
}
253266

254267
# Read side: `readCurrentState` must return the CFn shape
255268
# (`{EventBridgeEnabled: <bool>}`), not the SDK's `{}` -- the state baseline
256-
# holds the CFn spelling and drift-calculator only descends into keys present
257-
# in state, so the SDK shape reported permanent phantom drift on every
258-
# EventBridge-enabled bucket. `cdkd drift` exits 0 only when it finds none.
259-
if ! node "${LOCAL_DIST}" drift "${STACK}" \
260-
--state-bucket "${STATE_BUCKET}" --region "${REGION}"; then
261-
echo "FAIL: cdkd drift reported drift on a clean, freshly-deployed stack" >&2
262-
echo " (pre-#1430 the EventBridge boolean read back as permanently missing)" >&2
263-
exit 1
264-
fi
265-
echo " no drift on a clean stack (#1430 read side)"
269+
# holds the CFn spelling, so the SDK shape reported permanent phantom drift on
270+
# every EventBridge-enabled bucket. `cdkd drift` exits 0 only when it finds none.
271+
assert_no_drift() { # $1 = phase label
272+
if ! node "${LOCAL_DIST}" drift "${STACK}" \
273+
--state-bucket "${STATE_BUCKET}" --region "${REGION}"; then
274+
echo "FAIL [$1]: cdkd drift reported drift on a clean, freshly-deployed stack" >&2
275+
echo " (pre-#1430 the EventBridge boolean read back as permanently missing)" >&2
276+
exit 1
277+
fi
278+
echo " [$1] no drift on a clean stack (#1430 read side)"
279+
}
280+
281+
assert_eventbridge_pair "phase 1"
282+
assert_no_drift "phase 1"
266283

267284
CREATION_P1="$(aws s3api list-buckets \
268285
--query "Buckets[?Name=='${BUCKET_NAME}'].CreationDate | [0]" --output text)"
@@ -293,6 +310,9 @@ if [ "${CREATION_P1}" != "${CREATION_P2}" ]; then
293310
fi
294311
echo " bucket identity preserved (CreationDate unchanged) — no replacement"
295312

313+
assert_eventbridge_pair "phase 2"
314+
assert_no_drift "phase 2"
315+
296316
# --- Phase 3: destroy --------------------------------------------------
297317
echo "==> Phase 3: destroy"
298318
node "${LOCAL_DIST}" destroy "${STACK}" --state-bucket "${STATE_BUCKET}" --region "${REGION}" --force
@@ -302,7 +322,7 @@ echo " bucket deleted"
302322

303323
assert_gone_eventually "legacy bucket ${LEGACY_BUCKET} still exists after destroy" aws s3api head-bucket --bucket "${LEGACY_BUCKET}" --region "${REGION}"
304324
assert_gone_eventually "EventBridge bucket ${EB_TRUE_BUCKET} still exists after destroy" aws s3api head-bucket --bucket "${EB_TRUE_BUCKET}" --region "${REGION}"
305-
echo " legacy bucket deleted"
325+
echo " legacy + EventBridge buckets deleted"
306326

307327
assert_gone "state file ${STATE_KEY} still exists after destroy" aws s3api head-object --bucket "${STATE_BUCKET}" --key "${STATE_KEY}"
308328
echo " cdkd state removed"

0 commit comments

Comments
 (0)