748 lines
29 KiB
C#
748 lines
29 KiB
C#
using System.Security.Cryptography;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
|
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
|
|
|
public sealed class PlayoutLaunchAuthorizationTests
|
|
{
|
|
private const string Capability =
|
|
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
|
|
|
[Fact]
|
|
public void Missing_gate_environment_preserves_the_standard_launch_profile()
|
|
{
|
|
using var environment = ClearGateEnvironment();
|
|
|
|
var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
|
|
|
|
Assert.False(authorization.IsGateA);
|
|
Assert.True(authorization.AllowsDatabaseWrites);
|
|
Assert.True(authorization.AllowsCompositionMutation);
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_gate_capture_preserves_existing_live_authorization_semantics()
|
|
{
|
|
using var environment = ClearGateEnvironment()
|
|
.Set(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
|
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
|
|
|
using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
|
|
|
|
Assert.False(authorization.IsGateA);
|
|
Assert.True(authorization.IsAuthorizedForThisLaunch);
|
|
Assert.Equal(
|
|
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
|
|
Environment.GetEnvironmentVariable(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_capture_consumes_static_live_bearer_and_second_capture_fails_closed()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
|
using var environment = GateEnvironment(
|
|
Capability,
|
|
"41308",
|
|
"638878000000000000",
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
|
.Set(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
|
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
|
|
|
using var first = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath));
|
|
PlayoutLaunchAuthorization? second = null;
|
|
Assert.Throws<PlayoutConfigurationException>(() =>
|
|
second = PlayoutLaunchAuthorization.CaptureFromEnvironment());
|
|
|
|
Assert.True(first.IsGateA);
|
|
Assert.True(first.IsAuthorizedForThisLaunch);
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
|
Assert.Null(second);
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_environment_is_captured_once_and_cleared_before_browser_startup()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
|
using var environment = GateEnvironment(
|
|
Capability,
|
|
"41308",
|
|
"638878000000000000",
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath));
|
|
|
|
Assert.True(authorization.IsGateA);
|
|
Assert.Equal(41308, authorization.ExpectedPgmProcessId);
|
|
Assert.Equal(638878000000000000, authorization.ExpectedPgmStartTimeUtcTicks);
|
|
Assert.Equal(expiry, authorization.ExpiresAtUtcTicks);
|
|
Assert.False(authorization.AllowsDatabaseWrites);
|
|
Assert.False(authorization.AllowsCompositionMutation);
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
|
|
|
environment.Set(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
|
new string('F', 64));
|
|
|
|
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("lowercase0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab")]
|
|
[InlineData("ABCDEF")]
|
|
[InlineData(null)]
|
|
public void Invalid_or_partial_gate_environment_fails_closed_and_is_cleared(
|
|
string? capability)
|
|
{
|
|
using var environment = ClearGateEnvironment()
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
|
capability)
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
|
|
"41308");
|
|
|
|
Assert.Throws<PlayoutConfigurationException>(
|
|
PlayoutLaunchAuthorization.CaptureFromEnvironment);
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
|
}
|
|
|
|
[Fact]
|
|
public void Malformed_first_capture_permanently_blocks_reinjection_and_clears_every_bearer()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
|
using var environment = ClearGateEnvironment()
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
|
"ABCDEF")
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
|
|
"41308")
|
|
.Set(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
|
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
|
|
|
Assert.Throws<PlayoutConfigurationException>(() =>
|
|
PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath)));
|
|
|
|
environment
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
|
Capability)
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
|
|
"41308")
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable,
|
|
"638878000000000000")
|
|
.Set(
|
|
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable,
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
|
.Set(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
|
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
|
|
|
PlayoutLaunchAuthorization? second = null;
|
|
Assert.Throws<PlayoutConfigurationException>(() =>
|
|
second = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath)));
|
|
|
|
Assert.Null(second);
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_gate_captures_have_one_success_and_all_later_calls_fail_closed()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
|
using var environment = GateEnvironment(
|
|
Capability,
|
|
"41308",
|
|
"638878000000000000",
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
|
.Set(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
|
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
|
using var start = new ManualResetEventSlim();
|
|
var tasks = Enumerable.Range(0, 8)
|
|
.Select(_ => Task.Run(() =>
|
|
{
|
|
start.Wait();
|
|
try
|
|
{
|
|
return (
|
|
Authorization: PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath)),
|
|
Error: (Exception?)null);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return (Authorization: (PlayoutLaunchAuthorization?)null, Error: exception);
|
|
}
|
|
}))
|
|
.ToArray();
|
|
|
|
start.Set();
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
var successful = Assert.Single(results, static result => result.Authorization is not null);
|
|
Assert.True(successful.Authorization!.IsGateA);
|
|
foreach (var failed in results.Where(static result => result.Authorization is null))
|
|
{
|
|
Assert.IsType<PlayoutConfigurationException>(failed.Error);
|
|
}
|
|
|
|
successful.Authorization.Dispose();
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0L)]
|
|
[InlineData(-1L)]
|
|
[InlineData(9000000001L)]
|
|
public void Expired_or_overlong_gate_environment_fails_closed_and_clears_every_value(
|
|
long expiryOffsetTicks)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + expiryOffsetTicks;
|
|
using var environment = GateEnvironment(
|
|
Capability,
|
|
"41308",
|
|
"638878000000000000",
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
Assert.Throws<PlayoutConfigurationException>(() =>
|
|
PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath)));
|
|
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
|
Assert.Null(Environment.GetEnvironmentVariable(
|
|
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_scene_wrong_hash_fails_closed_and_does_not_leave_a_lease()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
|
using var environment = GateEnvironment(
|
|
Capability,
|
|
"41308",
|
|
"638878000000000000",
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
Assert.Throws<PlayoutConfigurationException>(() =>
|
|
PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
new string('A', 64)));
|
|
|
|
using var write = File.Open(
|
|
scenePath,
|
|
FileMode.Open,
|
|
FileAccess.Write,
|
|
FileShare.ReadWrite | FileShare.Delete);
|
|
Assert.True(write.CanWrite);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_scene_native_read_lease_blocks_write_and_delete_until_native_shutdown()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
|
using var environment = GateEnvironment(
|
|
Capability,
|
|
"41308",
|
|
"638878000000000000",
|
|
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
|
clock,
|
|
scenePath,
|
|
SceneHash(scenePath));
|
|
|
|
using (var secondReader = File.Open(
|
|
scenePath,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read))
|
|
{
|
|
Assert.True(secondReader.CanRead);
|
|
}
|
|
|
|
Assert.ThrowsAny<IOException>(() => File.Open(
|
|
scenePath,
|
|
FileMode.Open,
|
|
FileAccess.Write,
|
|
FileShare.ReadWrite | FileShare.Delete));
|
|
Assert.ThrowsAny<IOException>(() => File.Delete(scenePath));
|
|
|
|
authorization.CompleteGateAPrepare();
|
|
Assert.ThrowsAny<IOException>(() => File.Delete(scenePath));
|
|
|
|
authorization.Dispose();
|
|
File.Delete(scenePath);
|
|
Assert.False(File.Exists(scenePath));
|
|
}
|
|
|
|
[Fact]
|
|
public void Native_expiry_at_operator_claim_is_terminal_and_cannot_be_rearmed()
|
|
{
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
|
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
|
Capability,
|
|
timeProvider: clock,
|
|
expiresAtUtcTicks: expiry);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(1));
|
|
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Connect,
|
|
cue: null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Expiry_between_operator_claim_and_engine_prepare_blocks_vendor_boundary()
|
|
{
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
|
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
|
Capability,
|
|
timeProvider: clock,
|
|
expiresAtUtcTicks: expiry);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
|
|
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
|
clock.Advance(TimeSpan.FromTicks(1));
|
|
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
GateACue()));
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
GateACue()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Connect_just_before_expiry_does_not_extend_later_prepare_authority()
|
|
{
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
|
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
|
Capability,
|
|
timeProvider: clock,
|
|
expiresAtUtcTicks: expiry);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
|
|
Assert.True(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Connect,
|
|
cue: null));
|
|
clock.Advance(TimeSpan.FromTicks(1));
|
|
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
GateACue()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Connect_claim_uses_monotonic_deadline_so_utc_rollback_cannot_extend_gate()
|
|
{
|
|
var clock = new ManualGateTimeProvider();
|
|
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
|
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
|
Capability,
|
|
timeProvider: clock,
|
|
expiresAtUtcTicks: expiry);
|
|
|
|
clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
|
|
clock.AdvanceMonotonic(TimeSpan.FromMinutes(1));
|
|
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Connect,
|
|
cue: null));
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
}
|
|
|
|
[Fact]
|
|
public void Capability_and_engine_prepare_are_each_one_shot_and_never_rearm()
|
|
{
|
|
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
|
var cue = GateACue();
|
|
|
|
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
|
Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
|
|
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.True(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
|
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
|
|
|
authorization.CompleteGateAPrepare();
|
|
|
|
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
|
}
|
|
|
|
[Fact]
|
|
public void Wrong_prepare_target_consumes_the_engine_phase_and_blocks_correction()
|
|
{
|
|
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
|
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
|
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
GateACue() with { SceneName = "5006", SceneFile = "5006.t2s" }));
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
GateACue()));
|
|
}
|
|
|
|
[Fact]
|
|
public void GateA_rejects_operator_fade_and_background_changes_before_engine_dispatch()
|
|
{
|
|
var blockedCues = new[]
|
|
{
|
|
GateACue() with { FadeDuration = 5 },
|
|
GateACue() with { Mutations = [] },
|
|
GateACue() with
|
|
{
|
|
Mutations = [new PlayoutUseBackground(true)]
|
|
},
|
|
GateACue() with
|
|
{
|
|
Mutations =
|
|
[
|
|
new PlayoutUseBackground(false),
|
|
new PlayoutUseBackground(false)
|
|
]
|
|
},
|
|
GateACue() with
|
|
{
|
|
Mutations = [new PlayoutSetBackgroundTexture("background.png")]
|
|
},
|
|
GateACue() with
|
|
{
|
|
Mutations = [new PlayoutSetBackgroundVideo("background.mp4", 0, 1)]
|
|
}
|
|
};
|
|
|
|
foreach (var cue in blockedCues)
|
|
{
|
|
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
|
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
cue));
|
|
Assert.False(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
GateACue()));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Real_5001_provider_cue_with_exact_disabled_background_claims_native_prepare()
|
|
{
|
|
var provider = new RegisteredLegacySceneCueProvider(
|
|
new SinglePageDataSource(new LegacySceneDataPage(
|
|
"s5001",
|
|
new S5001DomesticStockSceneData(
|
|
LegacyDomesticEquityMarket.Kospi,
|
|
S5001DomesticStockMode.Current,
|
|
S5001NxtBadge.None,
|
|
"Samsung Electronics",
|
|
ScenePriceDirection.Up,
|
|
70_000,
|
|
500,
|
|
0.72m),
|
|
1)));
|
|
var page = await provider.CreatePageAsync(
|
|
new LegacyPlaylistEntry("gate-a-5001", "5001", FadeDuration: 6),
|
|
pageIndexZeroBased: 0);
|
|
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
|
|
|
Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]);
|
|
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
|
Assert.True(authorization.TryClaimEngineCommand(
|
|
PlayoutOperation.Prepare,
|
|
page.Cue));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_operator_claims_have_exactly_one_winner()
|
|
{
|
|
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
|
using var start = new ManualResetEventSlim();
|
|
var tasks = Enumerable.Range(0, 16)
|
|
.Select(_ => Task.Run(() =>
|
|
{
|
|
start.Wait();
|
|
return authorization.TryClaimGateAPrepare(Capability);
|
|
}))
|
|
.ToArray();
|
|
|
|
start.Set();
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
Assert.Single(results, static result => result);
|
|
}
|
|
|
|
[Fact]
|
|
public void GateA_options_require_the_exact_closed_profile()
|
|
{
|
|
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
|
var valid = GateAOptions();
|
|
|
|
PlayoutEngineFactory.ValidateGateAOptions(valid, authorization);
|
|
|
|
foreach (var invalid in new[]
|
|
{
|
|
GateAOptions().WithMode(PlayoutMode.DryRun),
|
|
GateAOptions().WithAllowlist("5001", "5006"),
|
|
GateAOptions().WithReconnect(true, 0),
|
|
GateAOptions().WithReconnect(false, 1),
|
|
GateAOptions().WithRefresh(null),
|
|
GateAOptions().WithFade(5),
|
|
GateAOptions().WithBackground(LegacySceneBackgroundKind.Texture),
|
|
GateAOptions().WithSceneDirectory(Path.GetTempPath()),
|
|
GateAOptions().WithClientPort(1),
|
|
GateAOptions().WithOutputChannel(0)
|
|
})
|
|
{
|
|
Assert.Throws<PlayoutConfigurationException>(() =>
|
|
PlayoutEngineFactory.ValidateGateAOptions(invalid, authorization));
|
|
}
|
|
}
|
|
|
|
private static PlayoutCue GateACue() => new(
|
|
"5001.t2s",
|
|
"5001",
|
|
FadeDuration: 6,
|
|
Mutations: [new PlayoutUseBackground(false)]);
|
|
|
|
private static PlayoutOptions GateAOptions() => new()
|
|
{
|
|
Mode = PlayoutMode.Live,
|
|
Host = "127.0.0.1",
|
|
Port = 30001,
|
|
TcpMode = 1,
|
|
LayoutIndex = 10,
|
|
TestSceneAllowlist = ["5001"],
|
|
TrustedLiveOutputEnabled = true,
|
|
ReconnectEnabled = false,
|
|
MaximumReconnectAttempts = 0,
|
|
MaximumAutomaticRefreshesPerTakeIn = 0,
|
|
LegacySceneFadeDuration = 6,
|
|
LegacySceneBackgroundKind = LegacySceneBackgroundKind.None,
|
|
SceneDirectory = PlayoutLaunchAuthorization.GateAApprovedSceneDirectory
|
|
};
|
|
|
|
private static string SceneHash(string path) =>
|
|
Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
|
|
|
private sealed class SinglePageDataSource(LegacySceneDataPage page)
|
|
: ILegacySceneDataSource
|
|
{
|
|
public Task<LegacySceneDataPage> LoadPageAsync(
|
|
LegacyPlaylistEntry entry,
|
|
int pageIndexZeroBased,
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(page);
|
|
}
|
|
|
|
private static EnvironmentScope ClearGateEnvironment()
|
|
{
|
|
PlayoutLaunchAuthorization.ResetGateACaptureLatchForTests();
|
|
return new EnvironmentScope()
|
|
.Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, null)
|
|
.Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, null)
|
|
.Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, null)
|
|
.Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, null)
|
|
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
|
|
}
|
|
|
|
private static EnvironmentScope GateEnvironment(
|
|
string capability,
|
|
string processId,
|
|
string startTimeUtcTicks,
|
|
string expiresAtUtcTicks) =>
|
|
ClearGateEnvironment()
|
|
.Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability)
|
|
.Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, processId)
|
|
.Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, startTimeUtcTicks)
|
|
.Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiresAtUtcTicks);
|
|
}
|
|
|
|
internal sealed class ManualGateTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _utcNow = new(2026, 7, 18, 0, 0, 0, TimeSpan.Zero);
|
|
private long _timestamp;
|
|
|
|
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _utcNow;
|
|
|
|
public override long GetTimestamp() => _timestamp;
|
|
|
|
public void Advance(TimeSpan duration)
|
|
{
|
|
_utcNow = _utcNow.Add(duration);
|
|
_timestamp += duration.Ticks;
|
|
}
|
|
|
|
public void AdvanceMonotonic(TimeSpan duration) =>
|
|
_timestamp += duration.Ticks;
|
|
|
|
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
|
|
}
|
|
|
|
internal static class GateAOptionTestExtensions
|
|
{
|
|
public static PlayoutOptions WithMode(this PlayoutOptions options, PlayoutMode mode)
|
|
{
|
|
options.Mode = mode;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithAllowlist(
|
|
this PlayoutOptions options,
|
|
params string[] scenes)
|
|
{
|
|
options.TestSceneAllowlist = [.. scenes];
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithReconnect(
|
|
this PlayoutOptions options,
|
|
bool enabled,
|
|
int attempts)
|
|
{
|
|
options.ReconnectEnabled = enabled;
|
|
options.MaximumReconnectAttempts = attempts;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithRefresh(this PlayoutOptions options, int? maximum)
|
|
{
|
|
options.MaximumAutomaticRefreshesPerTakeIn = maximum;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithFade(this PlayoutOptions options, int fade)
|
|
{
|
|
options.LegacySceneFadeDuration = fade;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithBackground(
|
|
this PlayoutOptions options,
|
|
LegacySceneBackgroundKind kind)
|
|
{
|
|
options.LegacySceneBackgroundKind = kind;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithSceneDirectory(
|
|
this PlayoutOptions options,
|
|
string sceneDirectory)
|
|
{
|
|
options.SceneDirectory = sceneDirectory;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithClientPort(
|
|
this PlayoutOptions options,
|
|
int clientPort)
|
|
{
|
|
options.ClientPort = clientPort;
|
|
return options;
|
|
}
|
|
|
|
public static PlayoutOptions WithOutputChannel(
|
|
this PlayoutOptions options,
|
|
int outputChannel)
|
|
{
|
|
options.OutputChannel = outputChannel;
|
|
return options;
|
|
}
|
|
}
|