fix(913): scripted playout callbacks authenticate with a build-scoped token
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

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
This commit is contained in:
2026-10-30 00:34:50 +01:00
co-authored by Claude Opus 5.5
parent bcf6f7e04d
commit 324dbdbca0
18 changed files with 636 additions and 33 deletions
@@ -0,0 +1,85 @@
using CliWrap;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling.ScriptedScheduling;
/// <summary>
/// The two Core halves of the scripted-build credential (ersatztv#913): the session store that mints and
/// validates a build-scoped token, and the process launch that hands it to the user's program. The filter
/// half — that the token is accepted on <c>ScriptedScheduleController</c> and nowhere else — is
/// <c>ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests</c>.
/// </summary>
[TestFixture]
public class ScriptedBuildTokenTests
{
[Test]
public void StartSession_Mints_A_256_Bit_Token_Distinct_From_The_Build_Id_And_Per_Build()
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession first = service.StartSession(Substitute.For<ISchedulingEngine>());
ScriptedBuildSession second = service.StartSession(Substitute.For<ISchedulingEngine>());
first.Token.Length.ShouldBe(64);
first.Token.ShouldAllBe(c => Uri.IsHexDigit(c));
first.Token.ShouldNotContain(first.BuildId.ToString("N"), Case.Insensitive);
second.Token.ShouldNotBe(first.Token);
second.BuildId.ShouldNotBe(first.BuildId);
}
[Test]
public void Token_Is_Valid_Only_For_Its_Own_Running_Build()
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession mine = service.StartSession(Substitute.For<ISchedulingEngine>());
ScriptedBuildSession other = service.StartSession(Substitute.For<ISchedulingEngine>());
service.IsBuildTokenValid(mine.BuildId, mine.Token).ShouldBeTrue();
service.IsBuildTokenValid(mine.BuildId, other.Token).ShouldBeFalse();
service.IsBuildTokenValid(mine.BuildId, mine.Token.ToUpperInvariant()).ShouldBeFalse();
service.IsBuildTokenValid(mine.BuildId, mine.Token[..^1]).ShouldBeFalse();
service.IsBuildTokenValid(mine.BuildId, "").ShouldBeFalse();
service.IsBuildTokenValid(mine.BuildId, null).ShouldBeFalse();
service.IsBuildTokenValid(Guid.NewGuid(), mine.Token).ShouldBeFalse();
service.EndSession(mine.BuildId);
service.IsBuildTokenValid(mine.BuildId, mine.Token).ShouldBeFalse();
service.IsBuildTokenValid(other.BuildId, other.Token).ShouldBeTrue();
}
[Test]
public void A_Mocked_Session_Has_No_Token_That_Validates()
{
// MockSession is the in-process test seam; it registers no token, so no presented value — the empty
// string included — authenticates against it.
var service = new ScriptedPlayoutBuilderService();
var buildId = Guid.NewGuid();
service.MockSession(Substitute.For<ISchedulingEngine>(), buildId).ShouldBeTrue();
service.GetEngine(buildId).ShouldNotBeNull();
service.IsBuildTokenValid(buildId, "").ShouldBeFalse();
service.IsBuildTokenValid(buildId, "anything").ShouldBeFalse();
}
[Test]
public void Launch_Hands_The_Token_To_The_Program_In_The_Environment_And_Not_On_The_Command_Line()
{
const string Token = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
string[] arguments = ["http://localhost:8409", Guid.NewGuid().ToString(), "reset", "user-arg"];
Command command = ScriptedPlayoutBuilder.CreateCommand("/scripts/run.py", arguments, Token);
command.EnvironmentVariables.ShouldContainKeyAndValue(ScriptedBuildToken.EnvironmentVariable, Token);
command.Arguments.ShouldNotContain(Token);
// The published argument contract is unchanged: host, build id, mode, then the user's own arguments.
command.Arguments.ShouldBe(string.Join(' ', arguments));
command.TargetFilePath.ShouldBe("/scripts/run.py");
command.Validation.ShouldBe(CommandResultValidation.None);
}
}