From 371ca2c7eef465b87500794da170440b53fef66f Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 21 Aug 2026 19:46:22 +0200 Subject: [PATCH 1/5] feat(779): test the deny path at the production config value, and derive full-replace field lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two conventions from #773's detector menu (F and G), each shipped as a decision record plus one gap actually filled rather than only described. Part 1 — deny path at the production value. Every assertion about the API read gating posture ran through a hand-written FakeApiKeyProvider that is HANDED the boolean, and no test in the repository constructed ApiKeyProvider at all. So the one line deriving that posture from configuration 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. Now covered across the whole matrix — absent, true/True/TRUE, false/False — through the real provider. Part 2 — full-replace asserts its field list. ScheduleItemResponseRoundTripTests is the release gate for the flat schedule-item DTO, and its comparer was itself a hand-copied field list: complete on the day it was written, structurally unable to report the day it stopped being. A 55th field would be compared by nobody while the test named for losslessness stayed green. It now derives the property set by reflection with one named exemption (Id), asserts every exemption still names a real property, and carries an anti-vacuity floor. Mutation proofs witnessed, not assumed: - default true->false reddens Read_Gating_Is_Required_When_The_Setting_Is_Absent alone (1 failed, 13 passed) - a bogus exemption name reddens the stale-exemption assert - floor 50->60 reddens with "only 54 properties were compared" Residual SPA optional-field drift filed as #807 rather than folded in. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) --- .../ScheduleItemResponseRoundTripTests.cs | 122 ++++++++++-------- .../Services/ApiKeyProviderTests.cs | 46 +++++++ docs/README.md | 2 + docs/decisions/README.md | 2 + .../deny-path-at-production-config-value.md | 71 ++++++++++ .../full-replace-asserts-field-list.md | 68 ++++++++++ 6 files changed, 257 insertions(+), 54 deletions(-) create mode 100644 docs/decisions/records/testing/deny-path-at-production-config-value.md create mode 100644 docs/decisions/records/testing/full-replace-asserts-field-list.md diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs index 0d34bd88d..6220a02d7 100644 --- a/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs +++ b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.ProgramSchedules; @@ -340,62 +342,74 @@ public class ScheduleItemResponseRoundTripTests r.PreferredSubtitleLanguageCode, r.SubtitleMode); + // ersatztv#779 (detector G): the compared field list is DERIVED from the DTO by reflection, + // never hand-copied. The previous version was a hand-written run of `b.X.ShouldBe(a.X)` lines. + // It was COMPLETE on the day it was written — every property but Id — and had no way + // to report the day it stopped being: a field added to ScheduleItemResponseModel simply went + // uncompared, and this "lossless round-trip" test kept passing while the round trip silently + // dropped it. That is #754's mechanism exactly (a hand-maintained mirror drifting from a + // 28-property DTO by one field, HTTP 200, no error), one altitude up — in the very test whose + // job is to catch losses. + // + // Properties deliberately NOT compared, each with its reason. A name here must still exist on + // the record (asserted below), so renaming a field cannot leave a stale exemption behind. + private static readonly System.Collections.Generic.HashSet RoundTripExemptProperties = + new(StringComparer.Ordinal) + { + // Server-assigned row identity. The PUT replaces the item set, so B's rows are new rows + // with new ids; equality here would assert the opposite of what the endpoint does. + nameof(ScheduleItemResponseModel.Id) + }; + private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b) { - b.Index.ShouldBe(a.Index); - b.StartType.ShouldBe(a.StartType); - b.StartTime.ShouldBe(a.StartTime); - b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior); - b.PlayoutMode.ShouldBe(a.PlayoutMode); - b.CollectionType.ShouldBe(a.CollectionType); - b.CollectionId.ShouldBe(a.CollectionId); - b.MultiCollectionId.ShouldBe(a.MultiCollectionId); - b.SmartCollectionId.ShouldBe(a.SmartCollectionId); - b.RerunCollectionId.ShouldBe(a.RerunCollectionId); - b.MediaItemId.ShouldBe(a.MediaItemId); - b.PlaylistId.ShouldBe(a.PlaylistId); - b.SearchTitle.ShouldBe(a.SearchTitle); - b.SearchQuery.ShouldBe(a.SearchQuery); - b.PlaybackOrder.ShouldBe(a.PlaybackOrder); - b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy); - b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups); - b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems); - b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize); - b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode); - b.MultipleMode.ShouldBe(a.MultipleMode); - b.MultipleCount.ShouldBe(a.MultipleCount); - b.PlayoutDuration.ShouldBe(a.PlayoutDuration); - b.TailMode.ShouldBe(a.TailMode); - b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts); - b.CustomTitle.ShouldBe(a.CustomTitle); - b.GuideMode.ShouldBe(a.GuideMode); - b.PreRollFillerId.ShouldBe(a.PreRollFillerId); - b.MidRollFillerId.ShouldBe(a.MidRollFillerId); - b.PostRollFillerId.ShouldBe(a.PostRollFillerId); - b.TailFillerId.ShouldBe(a.TailFillerId); - b.FallbackFillerId.ShouldBe(a.FallbackFillerId); - b.WatermarkIds.ShouldBe(a.WatermarkIds); - b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds); - b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode); - b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle); - b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode); - b.SubtitleMode.ShouldBe(a.SubtitleMode); - b.CollectionName.ShouldBe(a.CollectionName); - b.MultiCollectionName.ShouldBe(a.MultiCollectionName); - b.SmartCollectionName.ShouldBe(a.SmartCollectionName); - b.RerunCollectionName.ShouldBe(a.RerunCollectionName); - b.PlaylistName.ShouldBe(a.PlaylistName); - b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId); - b.MediaItemName.ShouldBe(a.MediaItemName); - b.PreRollFillerName.ShouldBe(a.PreRollFillerName); - b.MidRollFillerName.ShouldBe(a.MidRollFillerName); - b.PostRollFillerName.ShouldBe(a.PostRollFillerName); - b.TailFillerName.ShouldBe(a.TailFillerName); - b.FallbackFillerName.ShouldBe(a.FallbackFillerName); - b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name))); - b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name))); - b.Name.ShouldBe(a.Name); - b.DurationEstimate.ShouldBe(a.DurationEstimate); + PropertyInfo[] properties = typeof(ScheduleItemResponseModel) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + + // A stale exemption is a silent hole: it would exempt nothing while reading as a reviewed + // decision, and the property it once named would be compared or not by accident. + foreach (string exempt in RoundTripExemptProperties) + { + properties.Any(p => p.Name == exempt).ShouldBeTrue( + $"'{exempt}' is exempted from the round-trip comparison but is not a property of " + + $"{nameof(ScheduleItemResponseModel)}; remove the stale exemption or fix the name."); + } + + var compared = 0; + foreach (PropertyInfo property in properties) + { + if (RoundTripExemptProperties.Contains(property.Name)) + { + continue; + } + + object? expected = property.GetValue(a); + object? actual = property.GetValue(b); + + if (expected is IEnumerable expectedSequence and not string) + { + // Collection-valued members (WatermarkIds, Watermarks, GraphicsElementIds, + // GraphicsElements) are reference-equal only by accident, so compare elementwise. + actual.ShouldNotBeNull($"{property.Name} was null on the round-tripped item"); + var actualSequence = (IEnumerable)actual; + actualSequence.Cast().ToList() + .ShouldBe(expectedSequence.Cast().ToList(), $"{property.Name} differs"); + } + else + { + actual.ShouldBe(expected, $"{property.Name} differs"); + } + + compared++; + } + + // Anti-vacuity: a reflective walk that reached nothing reports success just as loudly as one + // that checked everything. The floor is deliberately near the real count (54 properties less + // one exemption) rather than `> 0`, so gutting the DTO cannot quietly shrink this test's + // reach. Raise it in the same change that adds fields. + compared.ShouldBeGreaterThanOrEqualTo( + 50, + $"only {compared} properties were compared; the round-trip check has lost its reach"); } private async Task SeedScheduleAndReferences(bool shuffleScheduleItems) diff --git a/ErsatzTV.Tests/Services/ApiKeyProviderTests.cs b/ErsatzTV.Tests/Services/ApiKeyProviderTests.cs index a78a906f7..cbdbe6ba4 100644 --- a/ErsatzTV.Tests/Services/ApiKeyProviderTests.cs +++ b/ErsatzTV.Tests/Services/ApiKeyProviderTests.cs @@ -126,6 +126,52 @@ public class ApiKeyProviderTests key.ShouldMatch("^[0-9a-f]{64}$"); } + // ---- Api:RequireKeyForReads, read through the REAL provider (ersatztv#779, detector F) ---- + // + // Every other assertion about the read-gating posture goes through a hand-written + // FakeApiKeyProvider that is HANDED the bool (ApiAuthorizationFilterTests, + // ApiKeyEndpointRequiresKeyTests). Those fakes prove the FILTER reacts to the flag; they cannot + // see the line that DERIVES it, because they never run it. Until these tests, nothing in the + // suite constructed ApiKeyProvider at all, so a mistyped configuration key or a flipped default + // would have left the whole suite green while shipping anonymous reads (#280/#282). + // + // Api:WriteKey is set in every case purely so ResolveKey returns before touching the real + // FileSystemLayout.ApiKeyPath — the constructor would otherwise generate and persist a key into + // the live config volume. It is deliberately NOT the subject of these tests. + private static ApiKeyProvider ProviderWith(params (string Key, string Value)[] settings) + { + var withKey = new List<(string, string)> { (ApiKeyProvider.WriteKeyConfigurationKey, "test-key") }; + withKey.AddRange(settings); + return new ApiKeyProvider(Config(withKey.ToArray()), NullLogger.Instance); + } + + [Test] + public void Read_Gating_Is_Required_When_The_Setting_Is_Absent() + { + // The shipped default, and the case a fixture that simply OMITS the field would test by + // accident. Asserted explicitly so it is a pinned decision rather than a coincidence. + ProviderWith().RequireKeyForReads.ShouldBeTrue(); + } + + [TestCase("true")] + [TestCase("True")] + [TestCase("TRUE")] + public void Read_Gating_Is_Required_At_The_Explicit_Production_Value(string configured) + { + // The DENY path at the production value, which is the half #756 showed can stay invisible: + // the absent case behaving correctly says nothing about the configured one. + ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured)) + .RequireKeyForReads.ShouldBeTrue(); + } + + [TestCase("false")] + [TestCase("False")] + public void Read_Gating_Is_Waived_Only_By_An_Explicit_Opt_Out(string configured) + { + ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured)) + .RequireKeyForReads.ShouldBeFalse(); + } + [Test] public void Returns_A_Usable_Key_Even_When_Persist_Fails() { diff --git a/docs/README.md b/docs/README.md index 726a52a29..f9a933a97 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ doc below, or that changes which sections a task signal points to.** | Auth / security-surface work | `docs/decisions/api-auth-security.md` | | CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` | | Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the two rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md` and `…/guard-ships-with-mutation-proof.md` | +| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch | +| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists | | Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green | | Writing code that reads live Gitea/remote state and then acts on it | `docs/decisions/records/process/check-and-use-pins-a-version.md`, then `docs/remote-state-inventory.md` — a new executable under `scripts/` (**excluding `scripts/tests/`**), `.claude/hooks/`, `.husky/` or `.gitea/workflows/` must acquire a row there before `scripts/tests/test_remote_state_inventory.py` goes green | | Finding every site that references a symbol (multi-site fix/sweep) | `docs/local-lsp-tooling.md` — which surface answers, and why a delegated agent must be pointed at the `csharp-lsp` MCP tools rather than the `LSP` tool | diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 939574815..4af092e6e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -192,10 +192,12 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](records/spa/topbar-primary-action.md) | | `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `