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);
}
}
@@ -1,11 +1,26 @@
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IScriptedPlayoutBuilderService
{
/// <summary>
/// Registers an engine under a caller-chosen build id with NO token, so
/// <see cref="IsBuildTokenValid" /> is false for it. The in-process test seam (ersatztv#563).
/// </summary>
bool MockSession(ISchedulingEngine schedulingEngine, Guid buildId);
Guid StartSession(ISchedulingEngine schedulingEngine);
/// <summary>Registers an engine under a fresh build id and mints the build-scoped token for it.</summary>
ScriptedBuildSession StartSession(ISchedulingEngine schedulingEngine);
ISchedulingEngine GetEngine(Guid buildId);
/// <summary>
/// True only when <paramref name="buildId" /> names a running build whose minted token equals
/// <paramref name="token" /> (constant-time). An ended build, a mocked build and an empty token are all false.
/// </summary>
bool IsBuildTokenValid(Guid buildId, string token);
void EndSession(Guid buildId);
}
@@ -0,0 +1,13 @@
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
/// <summary>
/// A running scripted build: the id the program addresses it by, and the build-scoped token it
/// authenticates with (<see cref="ScriptedBuildToken" />). A class rather than a record so that its
/// <c>ToString()</c> — and therefore a structured-log argument — never prints the token.
/// </summary>
public sealed class ScriptedBuildSession(Guid buildId, string token)
{
public Guid BuildId { get; } = buildId;
public string Token { get; } = token;
}
@@ -0,0 +1,25 @@
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
/// <summary>
/// The credential a scripted-playout program presents when it calls back into
/// <c>/api/v1/scripted/playout/build/{buildId}</c> (ersatztv#913, decision
/// <c>api.scripted-build-token</c>). <see cref="ScriptedPlayoutBuilder" /> mints one per build, hands it to
/// the program in <see cref="EnvironmentVariable" />, and the global API authorization filter accepts it in
/// <see cref="HeaderName" /> — for that build's endpoints only, and only while the build is running.
/// </summary>
public static class ScriptedBuildToken
{
/// <summary>
/// The request header the program sends the token in. A custom header, so — like the machine
/// <c>X-Api-Key</c> — a browser cannot attach it cross-origin, and a token-authenticated mutation needs
/// no CSRF header.
/// </summary>
public const string HeaderName = "X-Scripted-Build-Token";
/// <summary>
/// The environment variable the token is delivered in. Not a command-line argument: the argument list is
/// logged at Information level and is visible to every local user through the process table, and a new
/// positional argument would shift the user's own arguments (the ones after <c>mode</c>) by one.
/// </summary>
public const string EnvironmentVariable = "ETV_SCRIPTED_BUILD_TOKEN";
}
@@ -27,7 +27,8 @@ public class ScriptedPlayoutBuilder(
{
var result = PlayoutBuildResult.Empty;
Guid buildId = scriptedPlayoutBuilderService.StartSession(schedulingEngine);
ScriptedBuildSession session = scriptedPlayoutBuilderService.StartSession(schedulingEngine);
Guid buildId = session.BuildId;
var timeoutSeconds = 30;
@@ -86,9 +87,7 @@ public class ScriptedPlayoutBuilder(
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token);
Command command = Cli.Wrap(scriptFile)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None);
Command command = CreateCommand(scriptFile, arguments, session.Token);
var commandResult = await command.ExecuteBufferedAsync(linkedCts.Token);
if (!string.IsNullOrWhiteSpace(commandResult.StandardOutput))
@@ -128,6 +127,18 @@ public class ScriptedPlayoutBuilder(
return result;
}
/// <summary>
/// The process launch for one build. The arguments are the published contract (host, build id, mode, then
/// the user's own); the build-scoped token travels in <see cref="ScriptedBuildToken.EnvironmentVariable" />
/// instead, because the global API authorization filter rejects an unauthenticated callback and the argument
/// list is logged (ersatztv#913). CliWrap layers the variable over the inherited environment.
/// </summary>
internal static Command CreateCommand(string scriptFile, IEnumerable<string> arguments, string buildToken) =>
Cli.Wrap(scriptFile)
.WithArguments(arguments)
.WithEnvironmentVariables(env => env.Set(ScriptedBuildToken.EnvironmentVariable, buildToken))
.WithValidation(CommandResultValidation.None);
private async Task<int> GetDaysToBuild(CancellationToken cancellationToken) =>
await configElementRepository
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
@@ -1,4 +1,6 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
@@ -6,22 +8,36 @@ namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
public class ScriptedPlayoutBuilderService : IScriptedPlayoutBuilderService
{
private readonly ConcurrentDictionary<Guid, ISchedulingEngine> _sessions = new();
private readonly ConcurrentDictionary<Guid, Session> _sessions = new();
public bool MockSession(ISchedulingEngine schedulingEngine, Guid buildId) =>
_sessions.TryAdd(buildId, schedulingEngine);
_sessions.TryAdd(buildId, new Session(schedulingEngine, []));
public Guid StartSession(ISchedulingEngine schedulingEngine)
public ScriptedBuildSession StartSession(ISchedulingEngine schedulingEngine)
{
var buildId = Guid.NewGuid();
_sessions[buildId] = schedulingEngine;
return buildId;
// 256 bits from the CSPRNG, independent of the build id: the id is logged and passed on the command
// line, so it must not double as the secret.
string token = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
_sessions[buildId] = new Session(schedulingEngine, Encoding.UTF8.GetBytes(token));
return new ScriptedBuildSession(buildId, token);
}
public ISchedulingEngine GetEngine(Guid buildId) => _sessions.GetValueOrDefault(buildId);
public ISchedulingEngine GetEngine(Guid buildId) =>
_sessions.TryGetValue(buildId, out Session session) ? session.Engine : null;
public bool IsBuildTokenValid(Guid buildId, string token) =>
!string.IsNullOrEmpty(token)
&& _sessions.TryGetValue(buildId, out Session session)
&& session.Token.Length > 0
&& CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(token), session.Token);
public void EndSession(Guid buildId)
{
_sessions.TryRemove(buildId, out _);
}
private sealed record Session(ISchedulingEngine Engine, byte[] Token);
}
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Security.Claims;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Filters;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
@@ -68,7 +69,7 @@ public class ApiAuthorizationFilterTests
}
private static ApiAuthorizationFilter MakeFilter(bool requireKeyForReads = true) =>
new(new FakeApiKeyProvider(Key, requireKeyForReads));
new(new FakeApiKeyProvider(Key, requireKeyForReads), new ScriptedPlayoutBuilderService());
private static void ShouldBeUnauthorized(AuthorizationFilterContext context)
{
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Filters;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
@@ -77,7 +78,8 @@ public class ApiKeyEndpointRequiresKeyTests
// The filter, on an /api path with the header MISSING, produces a 401 exactly when the predicate
// says the endpoint requires a key. Drive the real filter and compare its decision to the predicate.
AuthorizationFilterContext context = MakeContext(method, requiresApiKey, skip);
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads)).OnAuthorization(context);
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads), new ScriptedPlayoutBuilderService())
.OnAuthorization(context);
bool filterGated = context.Result is not null;
filterGated.ShouldBe(expected);
@@ -94,7 +96,8 @@ public class ApiKeyEndpointRequiresKeyTests
.ShouldBeTrue();
AuthorizationFilterContext context = MakeContext("POST", requiresApiKey: false, skip: false, path: "/iptv/x.m3u");
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true)).OnAuthorization(context);
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true), new ScriptedPlayoutBuilderService())
.OnAuthorization(context);
context.Result.ShouldBeNull();
}
@@ -0,0 +1,306 @@
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Filters;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Filters;
/// <summary>
/// The authorization decision for the scripted-playout callback surface (ersatztv#913), driven through the
/// REAL <see cref="ApiAuthorizationFilter" /> with the endpoint metadata MVC builds for
/// <see cref="ScriptedScheduleController" /> under the production <c>Startup.ConfigureServices</c> — not a
/// hand-assembled attribute list, so a controller-level attribute that is added, dropped or renamed shows up
/// here exactly as it does at runtime. Before #913 a scripted program presented no credential and every
/// callback was a 401; the program now presents the build-scoped token <c>ScriptedPlayoutBuilder</c> mints,
/// and the tests below pin both halves: that token opens this controller for its own running build, and
/// nothing about it opens anything else.
/// </summary>
[TestFixture]
public class ScriptedScheduleAuthorizationTests
{
private const string MachineKey = "machine-key";
private static readonly IReadOnlyList<ControllerActionDescriptor> ScriptedActions = LoadScriptedActions();
private sealed class FakeApiKeyProvider(bool requireKeyForReads) : IApiKeyProvider
{
public string ApiKey => MachineKey;
public bool RequireKeyForReads { get; } = requireKeyForReads;
}
[Test]
public void Scan_Found_Every_Scripted_Action()
{
// Guards the tests below against passing over an empty population (e.g. a part-discovery change that
// stops MVC seeing the controller). The count is DERIVED from the controller's own public actions.
int declared = typeof(ScriptedScheduleController)
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.DeclaredOnly)
.Count(m => m.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true).Length > 0);
declared.ShouldBeGreaterThan(0);
ScriptedActions.Count.ShouldBe(declared);
}
[Test]
public void Scripted_Controller_Is_Gated_Not_Exempt_At_Every_Read_Setting()
{
// The #913 choice: the controller stays under the global filter (a credential is required) rather than
// being made [SkipApiAuthorization]. Every action requires a credential at the production default
// (Api:RequireKeyForReads=true), and every MUTATING action requires one even with reads opted open.
foreach (ControllerActionDescriptor action in Actions())
{
action.EndpointMetadata.OfType<SkipApiAuthorizationAttribute>().ShouldBeEmpty(action.ActionName);
action.EndpointMetadata.OfType<AcceptsScriptedBuildTokenAttribute>().ShouldNotBeEmpty(action.ActionName);
string method = MethodOf(action);
ApiAuthorizationFilter.EndpointRequiresKey(method, action.EndpointMetadata, requireKeyForReads: true)
.ShouldBeTrue(action.ActionName);
ApiAuthorizationFilter.EndpointRequiresKey(method, action.EndpointMetadata, requireKeyForReads: false)
.ShouldBe(!HttpMethods.IsGet(method), action.ActionName);
}
}
[Test]
public void Token_Route_Value_Is_The_Controllers_Build_Id_Parameter()
{
// The filter reads the build id from the route value the marker names. A rename of the route parameter
// would make every token silently fail to validate (back to a 401 per callback), so pin the coupling.
foreach (ControllerActionDescriptor action in Actions())
{
action.AttributeRouteInfo.ShouldNotBeNull();
action.AttributeRouteInfo!.Template.ShouldNotBeNull();
action.AttributeRouteInfo.Template!.ShouldContain(
"{" + AcceptsScriptedBuildTokenAttribute.RouteValueName + ":guid}",
Case.Sensitive,
action.ActionName);
}
}
[Test]
public void No_Credential_Is_A_401_On_Every_Scripted_Action()
{
// The deny branch at the production read setting: what every callback got before #913, and what a
// program that ignores the token still gets.
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, session.BuildId);
Filter(service).OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedObjectResult>(action.ActionName);
}
}
[Test]
public void With_Reads_Opted_Open_Only_The_Mutations_Still_Need_A_Credential()
{
// The opt-out value of Api:RequireKeyForReads: the filter's own deny branch, not just the predicate.
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
var filter = new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: false), service);
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, session.BuildId);
filter.OnAuthorization(context);
if (HttpMethods.IsGet(MethodOf(action)))
{
context.Result.ShouldBeNull(action.ActionName);
}
else
{
context.Result.ShouldBeOfType<UnauthorizedObjectResult>(action.ActionName);
}
}
}
[Test]
public void Running_Builds_Own_Token_Is_Accepted_On_Every_Scripted_Action()
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, session.BuildId);
context.HttpContext.Request.Headers[ScriptedBuildToken.HeaderName] = session.Token;
Filter(service).OnAuthorization(context);
// No X-CSRF on the mutating actions: a custom-header credential is CSRF-immune, like the machine key.
context.Result.ShouldBeNull(action.ActionName);
}
}
[Test]
public void Token_Of_Another_Running_Build_Is_A_401()
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession mine = service.StartSession(Substitute.For<ISchedulingEngine>());
ScriptedBuildSession other = service.StartSession(Substitute.For<ISchedulingEngine>());
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, mine.BuildId);
context.HttpContext.Request.Headers[ScriptedBuildToken.HeaderName] = other.Token;
Filter(service).OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedObjectResult>(action.ActionName);
}
}
[Test]
public void Token_Is_A_401_Once_Its_Build_Has_Ended()
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
service.EndSession(session.BuildId);
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, session.BuildId);
context.HttpContext.Request.Headers[ScriptedBuildToken.HeaderName] = session.Token;
Filter(service).OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedObjectResult>(action.ActionName);
}
}
[TestCase("")]
[TestCase("not-the-token")]
public void Wrong_Or_Empty_Token_Is_A_401(string presented)
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, session.BuildId);
context.HttpContext.Request.Headers[ScriptedBuildToken.HeaderName] = presented;
Filter(service).OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedObjectResult>(action.ActionName);
}
}
[Test]
public void Token_Opens_Nothing_Outside_The_Marked_Controller()
{
// The same valid token and the same build-id route value, on an endpoint that lacks the marker, is a 401:
// the marker — not the shape of the route — is what scopes the credential.
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = HttpMethods.Post;
httpContext.Request.Path = "/api/v1/channels";
httpContext.Request.Headers[ScriptedBuildToken.HeaderName] = session.Token;
var routeData = new RouteData();
routeData.Values[AcceptsScriptedBuildTokenAttribute.RouteValueName] = session.BuildId.ToString();
var actionContext = new ActionContext(httpContext, routeData, new ActionDescriptor { EndpointMetadata = [] });
var context = new AuthorizationFilterContext(actionContext, []);
Filter(service).OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedObjectResult>();
}
[Test]
public void Machine_Key_Is_Still_Accepted_On_Every_Scripted_Action()
{
var service = new ScriptedPlayoutBuilderService();
ScriptedBuildSession session = service.StartSession(Substitute.For<ISchedulingEngine>());
foreach (ControllerActionDescriptor action in Actions())
{
AuthorizationFilterContext context = MakeContext(action, session.BuildId);
context.HttpContext.Request.Headers[ApiAuthorizationFilter.HeaderName] = MachineKey;
Filter(service).OnAuthorization(context);
context.Result.ShouldBeNull(action.ActionName);
}
}
// Every per-action test iterates through this, so none of them can pass by looping over nothing.
private static IReadOnlyList<ControllerActionDescriptor> Actions()
{
ScriptedActions.ShouldNotBeEmpty();
return ScriptedActions;
}
private static ApiAuthorizationFilter Filter(ScriptedPlayoutBuilderService service) =>
new(new FakeApiKeyProvider(requireKeyForReads: true), service);
private static string MethodOf(ActionDescriptor action) =>
action.EndpointMetadata.OfType<IHttpMethodMetadata>().SelectMany(m => m.HttpMethods).Single();
private static AuthorizationFilterContext MakeContext(ControllerActionDescriptor action, Guid buildId)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = MethodOf(action);
httpContext.Request.Path = "/" + action.AttributeRouteInfo!.Template!
.TrimStart('/')
.Replace("{" + AcceptsScriptedBuildTokenAttribute.RouteValueName + ":guid}", buildId.ToString(), StringComparison.Ordinal);
var routeData = new RouteData();
routeData.Values[AcceptsScriptedBuildTokenAttribute.RouteValueName] = buildId.ToString();
var actionContext = new ActionContext(httpContext, routeData, action);
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
}
// The action descriptors MVC builds from the production service registration — the same objects whose
// EndpointMetadata the filter reads at runtime.
private static List<ControllerActionDescriptor> LoadScriptedActions()
{
var settings = new Dictionary<string, string?>
{
["provider"] = "sqlite",
["ConnectionStrings:Data"] = "Data Source=:memory:"
};
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
var environment = Substitute.For<IWebHostEnvironment>();
environment.ApplicationName.Returns("ErsatzTV");
environment.EnvironmentName.Returns("Development");
environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.ContentRootFileProvider.Returns(new NullFileProvider());
environment.WebRootFileProvider.Returns(new NullFileProvider());
// The host registers the environment before Startup runs; MVC reads ApplicationName from that
// registration to discover the ErsatzTV assembly's controllers, so without it the scan finds none.
var services = new ServiceCollection();
services.AddSingleton(environment);
new Startup(configuration, environment).ConfigureServices(services);
using ServiceProvider provider = services.BuildServiceProvider();
return provider.GetRequiredService<IActionDescriptorCollectionProvider>()
.ActionDescriptors
.Items
.OfType<ControllerActionDescriptor>()
.Where(d => d.ControllerTypeInfo.AsType() == typeof(ScriptedScheduleController))
.ToList();
}
}
@@ -4,11 +4,19 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
/// <summary>
/// The callback surface a scripted playout's program drives during a build. Under the global API
/// authorization filter like every other controller — not <c>[SkipApiAuthorization]</c> — and additionally
/// accepts the build-scoped token <c>ScriptedPlayoutBuilder</c> hands the program, so the program needs
/// neither the machine key nor a session (ersatztv#913).
/// </summary>
[ApiController]
[AcceptsScriptedBuildToken]
[EndpointGroupName("scripted-schedule")]
[Route("/api/v1/scripted/playout/build/{buildId:guid}")]
public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedPlayoutBuilderService) : ControllerBase
@@ -0,0 +1,19 @@
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
/// <summary>
/// Lets <see cref="ApiAuthorizationFilter" /> accept a scripted build's own token
/// (<see cref="ScriptedBuildToken.HeaderName" />) as a credential, in addition to the machine key and a
/// session (ersatztv#913, decision <c>api.scripted-build-token</c>). The token is checked against the build
/// named by the <see cref="RouteValueName" /> route value, so it authenticates only that build's callbacks,
/// and only while that build runs. It does NOT exempt the endpoint: a request presenting none of the three
/// credentials is still a 401. Carried by <c>ScriptedScheduleController</c> alone.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class AcceptsScriptedBuildTokenAttribute : Attribute, IFilterMetadata
{
/// <summary>The route parameter that names the build the token must belong to.</summary>
public const string RouteValueName = "buildId";
}
+31 -1
View File
@@ -1,5 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -15,6 +17,8 @@ namespace ErsatzTV.Filters;
/// <item>a matching machine <c>X-Api-Key</c> header (MCP / external clients), or</item>
/// <item>an authenticated session principal (browser cookie, from OIDC or local login).</item>
/// </list>
/// An endpoint carrying <see cref="AcceptsScriptedBuildTokenAttribute" /> also accepts a running scripted
/// build's own token (<see cref="ScriptedBuildToken.HeaderName" />), for that build only (issue #913).
/// Which endpoints require authentication is decided by <see cref="EndpointRequiresKey" /> — the
/// single predicate shared with OpenAPI generation — so the spec can never drift from enforcement:
/// mutating verbs are always fail-closed; reads are gated when <c>Api:RequireKeyForReads</c> is set
@@ -28,7 +32,9 @@ namespace ErsatzTV.Filters;
/// custom header — forces a CORS preflight that a cross-site attacker page cannot satisfy.
/// </para>
/// </summary>
public class ApiAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthorizationFilter
public class ApiAuthorizationFilter(
IApiKeyProvider apiKeyProvider,
IScriptedPlayoutBuilderService scriptedPlayoutBuilderService) : IAuthorizationFilter
{
public const string HeaderName = "X-Api-Key";
@@ -62,6 +68,13 @@ public class ApiAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthoriza
return;
}
// 1b. A scripted build's own token, on the one controller that opts in. Like the machine key it is a
// custom header, so it is CSRF-immune.
if (HasValidScriptedBuildToken(context))
{
return;
}
// 2. Authenticated session (cookie principal from OIDC or local login).
if (context.HttpContext.User?.Identity?.IsAuthenticated == true)
{
@@ -128,6 +141,23 @@ public class ApiAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthoriza
|| endpointMetadata.OfType<RequiresAuthenticationAttribute>().Any();
}
private bool HasValidScriptedBuildToken(AuthorizationFilterContext context)
{
if (!context.ActionDescriptor.EndpointMetadata.OfType<AcceptsScriptedBuildTokenAttribute>().Any())
{
return false;
}
if (!context.HttpContext.Request.Headers.TryGetValue(ScriptedBuildToken.HeaderName, out StringValues provided))
{
return false;
}
return context.RouteData.Values.TryGetValue(AcceptsScriptedBuildTokenAttribute.RouteValueName, out object value)
&& Guid.TryParse(value?.ToString(), out Guid buildId)
&& scriptedPlayoutBuilderService.IsBuildTokenValid(buildId, provided.ToString());
}
private static bool IsMutating(string httpMethod) =>
HttpMethods.IsPost(httpMethod)
|| HttpMethods.IsPut(httpMethod)
+7
View File
@@ -1024,6 +1024,13 @@ OpenAPI generation, so the spec can't drift). When you add an endpoint:
auth surface must be reachable before a caller is authenticated, and its one sensitive action
(`POST /api/v1/auth/password`) self-checks the principal. `ApiControllerSecurityTests` asserts these two are the
**only** auth-exempt controllers.
- **The scripted-playout callback** (`ScriptedScheduleController`, `/api/v1/scripted/playout/build/{buildId}`) is
**not** exempt. It carries `[AcceptsScriptedBuildToken]`, which adds a third accepted credential on that
controller only: the per-build token `ScriptedPlayoutBuilder` passes the program in `ETV_SCRIPTED_BUILD_TOKEN`,
sent back as `X-Scripted-Build-Token`, valid for the build named by the route's `buildId` until the build ends.
The marker is not a general opt-in: its token is a scripted build's, so no other controller carries it. Why a
scoped credential rather than a third `[SkipApiAuthorization]` controller: `api.scripted-build-token`; pinned by
`ScriptedScheduleAuthorizationTests`.
- **The `/api/v1/auth/*` surface** (`AuthController`, `[ApiExplorerSettings(IgnoreApi = true)]` → excluded from the
OpenAPI doc, whose audience is machine clients): `GET config` (what auth options exist + `setupRequired`),
`GET session`, `POST setup` (first-run claim), `POST login`, `POST logout`, `POST password`, `GET machine-key`
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
---
key: api.scripted-build-token
title: '2026-10-29 — A scripted playout''s program authenticates its callbacks with a build-scoped token, not by exempting the callback controller from API auth (#913)'
status: active
since: '2026-10-29'
supersedes: none
superseded-by: none
rule: '`ScriptedScheduleController` (`/api/v1/scripted/playout/build/{buildId:guid}`) stays under the global `ApiAuthorizationFilter` and is NOT `[SkipApiAuthorization]`. It carries `[AcceptsScriptedBuildToken]`, which lets the filter accept one more credential on that controller alone: the token `ScriptedPlayoutBuilderService.StartSession` mints for each build (256 bits from the CSPRNG, independent of the build id), presented in the `X-Scripted-Build-Token` header, compared in constant time, and valid only for the build named by the `buildId` route value and only until `EndSession`. `ScriptedPlayoutBuilder` hands it to the program in the `ETV_SCRIPTED_BUILD_TOKEN` environment variable — never on the command line, which is logged and visible in the process table, and whose positional contract (host, build id, mode, then the user''s own arguments) is unchanged. The machine key and a session are still accepted there; a request carrying none of the three is a 401. The token is a custom header, so like the machine key it needs no `X-CSRF`.'
signals: 'scripted playout build 401 · scripted script gets no credential · build-scoped credential · ETV_SCRIPTED_BUILD_TOKEN · X-Scripted-Build-Token · AcceptsScriptedBuildToken · why not SkipApiAuthorization on the scripted controller · scripted build silently empty · paths: `ErsatzTV/Filters/ApiAuthorizationFilter.cs`, `ErsatzTV/Filters/AcceptsScriptedBuildTokenAttribute.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `scripts/scripted-schedules/entrypoint.py` · issues: #913, #563, #285, #197'
mechanics: '`docs/api-conventions.md` §9 (the credential list); pinned by `ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests.cs` (the filter decision over the endpoint metadata MVC builds from the production `Startup`) and `ErsatzTV.Core.Tests/Scheduling/ScriptedScheduling/ScriptedBuildTokenTests.cs` (minting, validation, and the launch environment)'
---
**The defect this settles.** The program a scripted playout runs calls back into
`ScriptedScheduleController` over `http://localhost:{UiPort}`, and that controller has been under the
fail-closed global filter since `security.fail-closed-api-auth`. The program was given no credential, so
every callback was a 401. A program that exits 0 after that failure — `entrypoint.py` catches the
client's `ApiException` and does — leaves a build that reports success with no items. Reproduced against
a live instance on 2026-10-29; the evidence is on #913.
**Why not `[SkipApiAuthorization]`, the one-line fix.** It would restore upstream behaviour for every
existing script, but it widens the auth-exempt set (`ApiControllerSecurityTests` pins it at the scanner
and auth controllers) by a whole MUTATING controller, and what would then stand in for the credential is
weaker than it looks:
- The host check in `Startup` does not make the endpoint localhost-only: a request on `Settings.UiPort`
passes it from any LAN peer. `[LocalhostOnly]`, the scanner's mitigation, reads
`RemoteIpAddress`, which the default trust-all `ForwardedHeaders` lets any peer set with
`X-Forwarded-For` (`security.session-auth-dual-credential`).
- The remaining gate would be the build id, which is logged at Information level on every build and is
in the program's argv. #285 already rejected a GUID as the sole gate for the scanner callback.
**Why not the machine key.** Handing the program `X-Api-Key` would work with no filter change, and the
program, which runs as the server's own user, could read `/config/api.key` anyway. It was rejected
because a leak is unbounded: a key that a user script echoes to stdout is the whole API for as long as
the key lives. A leaked build token opens one in-memory build's callbacks for at most the build timeout.
**Why the environment and not a fourth argument.** Arguments after the mode belong to the user: the
fork's own driver takes its script module name there. Inserting a positional would shift them by one and
break every existing script, and the argument list is the thing the server logs.
**What a script must now do.** Read `ETV_SCRIPTED_BUILD_TOKEN` and send it as `X-Scripted-Build-Token`
on every callback. `entrypoint.py` does this with `ApiClient.set_default_header`, because the
`scripted-schedule` OpenAPI document declares no security scheme, so the generated `etv_client` has no
setting for it. A third-party script that ignores the variable gets the same 401 it got before this
record; that compatibility cost is accepted, since that script never worked against this fork either.
**Residue, named.** The filter decision is tested in-process against the real MVC metadata; the Kestrel
hop and the `Cli.Wrap` launch remain outside the automated suite (`testing.scripted-engine-in-process-net`),
so `ScriptedPlayoutBuilder.Build` reaching `CreateCommand`, the one place the variable is set, is held by
the live run on #913 rather than by a test.
@@ -5,8 +5,8 @@ status: active
since: '2026-09-05'
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
superseded-by: none
rule: '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/`ApiAuthorizationFilter` 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. 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.'
signals: '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; 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` · issues: #563, #381, #395, #163'
rule: '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.'
signals: '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'
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
---
@@ -44,9 +44,10 @@ wrong — the four adapter mappings above are real behaviour that only a test ca
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; and
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list — it is
`ApiJsonSettings` itself, and two tests hold the two ways it can be replaced. `ApiJsonSettings` exists so
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
@@ -62,13 +63,13 @@ 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.
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
`[SkipApiAuthorization]` while
`ScriptedPlayoutBuilder` passes the script only host, build id and mode, with no `X-Api-Key`, and
`entrypoint.py` builds its client with no credential. Whether a scripted build therefore 401s on its first
POST in this fork is unverified against a live instance and is tracked in ersatztv#913; it is recorded here
as the concrete shape of what an engine-level net cannot see, so the scope-out is not read as a claim of
equivalence.
**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
+9 -5
View File
@@ -74,6 +74,8 @@ at all (ersatztv#563, decision `testing.scripted-engine-in-process-net`).
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`), bound with the production body-binder configuration (`ApiJsonSettings`) and replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use (both trimming actions target an instant *between* two content boundaries, so each one's `trim` argument reaches the engine's trim branch rather than sitting inert), plus the four adapter mappings: 404 on an unknown build id, 400 on an unparseable playback order, a silent fall back to `FillerKind.None` on an unparseable filler kind, and the engine's no-progress `InvalidOperationException` translated to a 400. |
| `ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs` | The enumerator-construction helper the Scripted and Sequential/YAML engines share (ersatztv#395). |
| `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` | What the replay's standalone binder does and does not share with the one MVC runs, so the paragraph below stays a measurement rather than a claim. |
| `ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests.cs` | The `ApiAuthorizationFilter` decision for every `ScriptedScheduleController` action, driven through the real filter over the endpoint metadata MVC builds from the production `Startup`: no credential is a 401 (with `Api:RequireKeyForReads` opted off, on the mutating actions only), the running build's own token is accepted (no `X-CSRF`), another build's token, an ended build's token, a wrong or empty token and the token on an unmarked endpoint are all 401, and the machine key still works (decision `api.scripted-build-token`, ersatztv#913). |
| `ErsatzTV.Core.Tests/Scheduling/ScriptedScheduling/ScriptedBuildTokenTests.cs` | Minting and validating the build-scoped token in `ScriptedPlayoutBuilderService`, and `ScriptedPlayoutBuilder.CreateCommand` putting it in the program's environment and not on its command line. |
The expected snapshot is a string constant in the test rather than a fourth golden file: the golden
harness lives in `ErsatzTV.Core.Tests`, which cannot reference a controller, and duplicating it would
@@ -81,15 +83,17 @@ create a second action-to-engine mapping — the thing the single-mapping design
**Not covered, and not scheduled to be** (measured 2026-09-05): the `Cli.Wrap` launch of the user's own
program — exit code, the `PlayoutScriptedScheduleTimeoutSeconds` timeout, stdout capture; Kestrel and the
`Startup` middleware, including the host/`Settings.UiPort` check that 404s a foreign request;
`ApiAuthorizationFilter`, which fail-closes every mutating verb for an endpoint without
`[SkipApiAuthorization]`; and MVC model binding as a *wrapper* — the input formatter, model validation
`Startup` middleware, including the host/`Settings.UiPort` check that 404s a foreign request; that
`ScriptedPlayoutBuilder.Build` launches through `CreateCommand`, the one place the build token is put in
the program's environment; and MVC model binding as a *wrapper* — the input formatter, model validation
(a non-nullable reference type picks up an implicit required check there) and the `[ApiController]`
automatic 400 either produces before the action runs, since the replay hands each action an
already-bound object. Hosting the real `Startup` would drag in the whole DI graph, a
hand-rolled minimal host would test a transport the product does not have, and the script is
user-authored by definition — so any committed script is a stand-in either way. The decision record
names the concrete blind spot this leaves, and it is tracked as ersatztv#913.
user-authored by definition — so any committed script is a stand-in either way. The
`ApiAuthorizationFilter` *decision* is the exception: it is a pure function of the request and the
endpoint metadata, so `ScriptedScheduleAuthorizationTests` covers it without a host — the blind spot the
decision record named, which was a real 401 on every callback (ersatztv#913).
The *serializer* inside that binding wrapper is covered rather than scoped out. The replay deserializes
fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings`, the same function `Startup` hands to
+8
View File
@@ -2,6 +2,7 @@
import argparse
import importlib
import os
import sys
from uuid import UUID
@@ -26,7 +27,14 @@ def main():
configuration = etv_client.Configuration(host=known_args.host)
# ErsatzTV mints a token per build and passes it in the environment; the scripted callback endpoints
# reject a request without it (ersatztv#913). A custom header, not the machine X-Api-Key.
build_token = os.environ.get("ETV_SCRIPTED_BUILD_TOKEN")
with etv_client.ApiClient(configuration) as api_client:
if build_token:
api_client.set_default_header("X-Scripted-Build-Token", build_token)
try:
define_content = script_module.define_content
reset_playout = script_module.reset_playout