Test the deny path with the production config value, and assert full-replace field lists #779
Closed
opened 2026-08-13 19:58:48 +02:00 by timothy
·
4 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Defect-shape hardening
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#779
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Spawned by the #773 root-cause analysis (
docs/defect-shapes-773.md§3, §4 detectors F and G). Two small conventions, filed together because both are "a test that never exercised the dangerous value."Part 1 — fail-open by default
A surface that defaults to permissive when config is absent or a step vanishes:
Api:WriteKeyis null/empty, which is the shipped defaultreview-verdict.ymlbut fail-OPEN indocker-build.yml's required jobs#756's lesson generalises: a fixture that omits a field tests only the default, so a fail-open in the production value stays invisible — 30 green tests did not see it. The rule: parametrise the whole matrix, including "config absent", and assert the deny path with the production value, not just the allow path.
Part 2 — destructive replace
A full-replace path that silently destroys state a reconcile-by-id would have kept:
ReplaceProgramScheduleItemsHandler", with the full stable-identity design deferredThe rule: a full-replace endpoint asserts its complete field list in a test, and prefers reconcile-by-id over delete-and-reinsert wherever child state exists.
Done-when
testing.deny-path-at-production-config-value,testing.full-replace-asserts-field-list, catalog regenerated,docs/README.mdtask-signal rows added (PR #808)ToolCatalogTestsalready covers the MCP mirror; the gap wasScheduleItemResponseRoundTripTests's own comparer (a hand-copied field list in the test whose job is catching exactly that), now derived by reflection with a count pin. SPA optional-field drift tracked as #807.git ls-filesover.claude/hooks/,.husky/,scripts/,.gitea/workflows/. Gap found and filled: no test in the repo constructedApiKeyProvider, soApi:RequireKeyForReadswas covered only through fakes handed the resolved bool. Now covered absent /true|True|TRUE/false|False/ non-boolean-throws. Remaining permissive branches are all hookexit 0escape hatches already carryingNONE-proof rows indocs/guard-inventory.mdand owned by #785.Review-verdict/h10= success @8387e16.Claiming this (Claude Code session, continuing from #778). Four-way check clear: no open PR references it (the three open PRs are Renovate),
git ls-remote --heads origin '*779*'empty, no prior comments,origin/mainfreshly fetched atad0bed458.Adjacency worth stating: #778 just landed and touched the same merge-consent hook, adding deny-path tests at production config values — which is Part 1 of this issue applied to one surface. This issue asks for that as a convention plus an audit of the others.
Releasing this claim — claimed in error at the tail of the #778 session, which was scoped to that one issue. No work was started: no branch, no commits, nothing beyond the claim itself.
in-progressremoved; free for the next session to pick up.One handoff note worth keeping, from the bundle scan done before releasing: #787 is a strong sibling for whoever takes this. It needs
MARKED_JOBSintest_ci_dropped_step_guard.pychecked against livestatus_check_contextsinstead of a dated comment, and #778 (merged asad0bed458) just built exactly that read in.claude/hooks/pretooluse-merge-consent.sh— aGET /branch_protectionswith a three-way present/unreadable/absent outcome and an explicit no-silent-fail-open stance. That is the mechanism #787 says it wants a real decision about, and it pairs naturally with this issue's Part 1 (config-gated guards audited for a deny-path test at the production value).Claiming this (fresh Claude Code session). Four-way check clear at
origin/main=ad0bed458: no open PR references #779 (the three open PRs are Renovate: #761, #804, #805),git ls-remote --heads origin '*779*'empty, the only prior comments are the previous session's claim and its explicit release.Bundle scan: #787 deliberately NOT folded in. It is the same milestone and adjacent in spirit, but its Done-when asks for a design decision about querying live branch protection from a credential-less test context — that is not the same shape as this issue's "test the deny path at the production value", and bundling it would let a real decision ride along on an audit. Leaving it for its own session.
Closing record
Outcome: Both conventions shipped as decision records, plus one real gap filled per part. PR #808 (squash-merged as
8387e16d9head; branch deleted).testing.deny-path-at-production-config-valuetesting.full-replace-asserts-field-listRoot cause: Not a bug fix, but each part found a live instance of the shape it describes.
Part 1 — the interesting variant is not "someone forgot to test the production value". Every assertion about the API read-gating posture went through a hand-written
FakeApiKeyProviderthat is handed the boolean, atrequireKeyForReads: true, i.e. the production value, asserting the deny branch. Those are good tests. And no test in the repository constructedApiKeyProvider, soconfiguration.GetValue(RequireKeyForReadsConfigurationKey, true)had never been executed by a test. Mistype the key or flip the default and every green deny-path assertion stays green while the build serves anonymous reads. The double stood exactly where the untested code was.Part 2 — the hand-copied field list was inside
ScheduleItemResponseRoundTripTests, the release gate whose job is catching exactly that loss. It covered every property butId, so it was complete and structurally unable to report the day it stopped being.Decisions/conventions changed: two records added (
testing.deny-path-at-production-config-value,testing.full-replace-asserts-field-list); catalog regenerated;docs/README.mdtask-signal map gained a row for each. None superseded.Reusable knowledge:
grep -c 'new <Type>('over the test projects is a cheap detector for a class nothing ever constructs.ConfigurationBinderreturns the default ONLY for a null section value. A present-but-non-boolean ("",1,yes) reachesBooleanConverterand throws at construction.Api__RequireKeyForReads=with nothing after it in a compose file is a refusing app, not silent anonymous reads. Fail-closed, now pinned.Id" (false universal; a partial id-less payload takes the id-based branch and reddens withId differs). Only the third round's withdrawal of the narrative — a two-row table of executed results and no explanation — survived. State the measurement and the code path you read; do not generalise from one executed case to a class of cases, and do not explain a mechanism you did not measure. A record is where such a story does the most damage, because it reads as checked.testing.guard-derives-population-from-sourcescopes out (#777). A name matcher overReplace*Handlermisses the ones that don't match and flags the ones that are already correct.ReplacePlayoutAlternateScheduleItemsHandleroperates onProgramScheduleAlternate, notPlayoutTemplate. They share onlyIAlternateScheduleItem; the names invite the conflation (I made it). And no table holds an FK toPlayoutTemplateIdorProgramScheduleAlternateId— verified against the model snapshot, not just a grep.Verification:
ErsatzTV.Tests2005 passed / 0 failed / 4 skipped;scripts/tests729 passed / 2 skipped (unchanged from baseline).true→falsereddens only the absent-cell test (1 failed / 13 passed); a bogus exemption name reddens the stale-exemption clause; pin 55→60 reddens with the real count;r.Id→nullfor index 0 reddens withId differs, should be 4 but was 7. The all-null variant is recorded as green — a negative result kept because it is the one that falsified two drafts.8387e16d9: 12 success, 1 skipped. Real durations, not fast-path skips — Build & test 8m48s, EF migrations 6m17s, Script tests 4m26s, Functional E2E 6m11s. (Formatting / API-docs-in-sync / Functional E2E had been cancelled by successive pushes on every earlier sha and only completed here —ci.cancelled-is-not-a-verdictapplies to those, they were never real reds.)review-verdict/h10= success @8387e16, read back from the API rather than trusted from the script's exit code.Deferred:
tsccatches required ones; no SPA-side equivalent of the MCP write-tool field test. Highest-probability recurrence of the shape today.exit 0escape hatches (ETV_ALLOW_DIRTY_PUSH,ETV_SKIP_REBASE_CHECK, absent creds inprepush-donewhen.sh, absentpython3indecisions-guard.sh, andmemory_pressurebeing macOS-only inpretooluse-agent-ram.sh). Each already carries aNONE-proof row indocs/guard-inventory.md. Splitting one guard's proof across two issues is how a row ends up claiming coverage twice and holding none.docs/defect-shapes-773.md§4 still grades detectors F and G rule-missing; this PR is what creates their rules. Stale-after-merge, no false statement inside either record.Docs updated:
docs/decisions/records/testing/deny-path-at-production-config-value.md(new),docs/decisions/records/testing/full-replace-asserts-field-list.md(new),docs/decisions/README.md(regenerated),docs/README.md(two task-signal rows).