Files
ersatztv/docs/decisions/records/testing/scripted-engine-in-process-net.md
T
timothyandClaude Opus 5.5 324dbdbca0
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Failing after 10s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 324dbdb
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(913): scripted playout callbacks authenticate with a build-scoped token
A scripted playout's program calls back into ScriptedScheduleController,
which is under the fail-closed global ApiAuthorizationFilter, but was
handed no credential. Reproduced 2026-10-29 on a live local instance at
origin/main e919b925f (scripts/e2e-local.sh, port 8510): the program's
GET .../context and POST .../add_collection both returned 401, and the
playout's buildStatus still read success=true with no items, because a
program that exits 0 after the failure (entrypoint.py catches
ApiException and does) looks like a clean build.

Fix: ScriptedPlayoutBuilderService.StartSession mints a 256-bit token per
build, independent of the build id; ScriptedPlayoutBuilder passes it in
ETV_SCRIPTED_BUILD_TOKEN (environment, not argv: argv is logged at INF
and a fourth positional would shift the user's own arguments);
ApiAuthorizationFilter accepts it as X-Scripted-Build-Token on endpoints
carrying [AcceptsScriptedBuildToken] (ScriptedScheduleController only),
for the build named by the buildId route value, until EndSession.
entrypoint.py sends it as a default header. [SkipApiAuthorization] on the
controller was rejected: it widens the auth-exempt set by a mutating
controller whose only remaining gate would be a logged GUID, and the
Startup host check does not make the endpoint localhost-only.

Tests: ScriptedScheduleAuthorizationTests drives the real filter over the
endpoint metadata MVC builds from the production Startup for all 28
actions; ScriptedBuildTokenTests covers minting/validation and the
launch environment. Clause mutations, each rebuilt and run 2026-10-29:
  - drop [AcceptsScriptedBuildToken] from the controller -> red:
    Running_Builds_Own_Token_Is_Accepted..., Scripted_Controller_Is_Gated...
  - disarm the filter's token clause (no return) -> red: Running_Builds_Own_Token...
  - delete the filter's marker check -> red: Token_Opens_Nothing_Outside...
  - add [SkipApiAuthorization] to the controller -> red: No_Credential...,
    Scripted_Controller_Is_Gated..., Token_Of_Another..., Token_Is_A_401_Once...,
    Wrong_Or_Empty_Token (x2), ApiControllerSecurityTests.Only_Scanner_And_Auth...
  - token comparison always true -> red: Token_Of_Another..., Wrong_Or_Empty("not-the-token")
  - drop WithEnvironmentVariables from CreateCommand -> red: Launch_Hands_The_Token...
  - RouteValueName "buildId" -> "id" -> red: Token_Route_Value_Is_The_Controllers...

Live E2E on the fixed build (port 8510, fresh config): the real
entrypoint.py with the generated etv_client copied from the
ersatztv-test image built 3 playout items from a scanned collection;
as the program, own token -> 200 on GET context and POST add_collection;
no header, wrong token, another build's id, and the token on
GET /api/v1/channels and POST /api/v1/collections -> 401; the token
after the build ended -> 401; token absent from the server log.

Docs: new record api.scripted-build-token; testing.scripted-engine-in-
process-net's 401 clause now records the outcome; api-conventions §9 and
testing.md updated; catalog regenerated. OpenAPI regenerated: no drift.

Fixes #913

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
2026-10-30 00:34:50 +01:00

11 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
testing.scripted-engine-in-process-net 2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563) active 2026-09-05 testing.scripted-playout-golden-deferred@2026-07-22 none Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it, each binding a body that separates the production binder from one plausible replacement: `Production_Body_Binder_Ignores_Required_Members` omits a C# `required` member, which Newtonsoft defaults and System.Text.Json rejects; `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` sends an explicit `"order": null`, which `NullValueHandling.Ignore` drops and a bare `JsonSerializerSettings` writes through into a 400. What is shared is the CONFIGURATION, never MVC's settings OBJECT: `ApiJsonSettings.Create()` applies it to a bare settings object, so MVC's `MaxDepth` of 32 and its `ProblemDetails`/`ValidationProblemDetails` converters are absent (measured 2026-09-05, pinned by `ApiJsonSettingsTests`); neither is reachable from a scripted request body, and no test may generalize from `Create()` to production past that. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. The `ApiAuthorizationFilter` DECISION for this controller is not residue: `ScriptedScheduleAuthorizationTests` drives the real filter over the endpoint metadata MVC builds from the production `Startup`, which is how the build-scoped credential of `api.scripted-build-token` is pinned (#913). Both fixtures must aim every trimming instruction at an instant BETWEEN two content boundaries: a pad or duration that happens to land on one never enters the engine's trim branch, so that action's `trim` argument reaches nothing the snapshot can show. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays. why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; scripted build token; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs`, `ErsatzTV/Serialization/ApiJsonSettings.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests.cs` · issues: #563, #381, #395, #163, #913 docs/testing.md → "Scripted playout coverage" and "Golden-file nets"

What carries over from the predecessor. Sequential (YAML) is golden-able and TZ-independent, and PlayoutBuildGoldenTests covers Classic / Block / Sequential. Scripted's end-to-end pipeline stays out of that net: ScriptedPlayoutBuilder builds nothing itself, it Cli.Wrap-executes a user-authored program, hands it http://localhost:{Settings.UiPort} plus a build id, and reads the result straight off the in-process engine afterwards. #395's ContentEnumeratorBuilderTests remains the direct net over the enumerator-construction switch Scripted and Sequential share.

What changed. The predecessor deferred the whole harness to #563 and described the controller as a "1:1 pass-through". Both are now settled: the coverage exists in-process, and the pass-through wording was wrong — the four adapter mappings above are real behaviour that only a test can hold.

Why not the full Kestrel + Cli.Wrap harness. Four measured obstacles, not a preference:

  1. The production transport is not "a controller behind Kestrel". Startup 404s any request whose Host is not localhost and whose Connection.LocalPort != Settings.UiPort (a mutable process-wide static), and ApiAuthorizationFilter fail-closes every mutating verb. A hand-rolled minimal host would test a transport the product does not have; the real Startup drags in the whole DI graph (TvContext, ffmpeg workers, search index, background services), which is what #381 deferred in the first place.
  2. The external program is user-authored by definition. Playout.ScheduleFile is an arbitrary command line, so any script we commit is a stand-in we wrote either way. The fork's own driver, scripts/scripted-schedules/entrypoint.py, imports etv_client, a generated package that exists only in the deploy environment, so it cannot run in the .NET test job without adding a client-generation step.
  3. It would put an interpreter and process spawning inside the pure in-memory .NET suite, with the 30s Cli.Wrap timeout nested inside CI's --blame-hang-timeout 2m — a new flake class on a shared runner.
  4. The stand-in loses nothing the harness would have added at the engine level. Replaying the fixture through the real controller and the real service keeps a single action-to-engine mapping under test; what is absent is the two transport hops plus MVC binding as a wrapper, and the wrapper is the shallow half — the serializer inside it is configured by ApiJsonSettings, the production configuration (not MVC's settings object, whose remaining defaults the rule enumerates).

The residue, named. Uncovered: process launch, exit code and timeout handling; Kestrel plus the forwarded-headers / compression / SPA-hosting middleware; MVC model binding as a wrapper — the input formatter (content-type selection, malformed-body handling), model validation (a non-nullable reference type picks up an implicit required check there) and the [ApiController] automatic 400 either produces before an action runs, since every test hands an action an already-bound object. Two things are deliberately NOT on this list. The ApiAuthorizationFilter decision is covered (see below). The serializer inside the binding wrapper is ApiJsonSettings itself, and two tests hold the two ways it can be replaced. ApiJsonSettings exists so that binder is defined once; it is not a drift detector, and the branch may not claim one, because nothing observes Startup.ConfigureServices and a byte-equal hand-copy of Apply is indistinguishable by construction. What the suites do separate is a mirror that has lost something. The mechanics doc tabulates which suite separates which half of the configuration, and what neither reaches.

Where the calibrating measurements are, and why not here. testing.mutation-claims-are-executed admits a sentence asserting that a specific mutation reddens — or does not redden — a named test only as a CLAIMS entry in scripts/tests/mutation_manifest.py, executed every run; otherwise it is not written. That harness resolves a proof to scripts/tests/<node id> and runs pytest, so an NUnit proof cannot be declared in it and this coverage is entirely NUnit. The binder and trim mutants run while building these tests therefore stay in ersatztv#563 — its pull request and the commit messages that ran them — dated. Durable prose carries the mechanism each test is built on instead — which serializer difference, which engine branch — because that is re-checkable by reading the code rather than by trusting a remembered outcome.

The filter decision is covered; the hop to it is not. An engine-level net cannot see ApiAuthorizationFilter, and that blind spot was real: the program was handed no credential, so every callback was a 401 and the build came back empty (reproduced live 2026-10-29, ersatztv#913). The fix, api.scripted-build-token, is held by ScriptedScheduleAuthorizationTests, which drives the real filter over the endpoint metadata MVC builds for ScriptedScheduleController from the production Startup — so a controller-level attribute added, dropped or renamed is seen as it is at runtime. Kestrel, the middleware and the Cli.Wrap launch that carries the token in its environment stay out, as above.

Determinism constraints on the fixtures. Both use Chronological order and only instant-preserving instructions (AddCount, AddAll, AddDuration, PadUntilExact). WaitUntil(TimeOnly) and PadUntil(string) read the LOCAL day and time-of-day, and PadToNext becomes offset-sensitive once a call has localized CurrentTime, so introducing any of them — or a shuffle order — reopens the timezone and seed question the fixtures were built to close.