Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs

603 lines
20 KiB
C#

using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
using System.Text.RegularExpressions;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutSafetyValidationTests : IDisposable
{
private readonly TemporarySceneDirectory _scenes =
TemporarySceneDirectory.Create("test-scene.t2s", "Video\\background.vrv");
[Theory]
[InlineData("Tornado2", true)]
[InlineData("tornado2-test", true)]
[InlineData("TORNADO2Preview", true)]
[InlineData("Tornado20", true)]
[InlineData("Tornado", false)]
[InlineData("PreviewTornado2", false)]
[InlineData(null, false)]
public void IsTornadoProcessName_UsesCaseInsensitiveTornado2Prefix(
string? processName,
bool expected)
{
Assert.Equal(expected, TornadoProcessProbe.IsTornadoProcessName(processName));
}
[Theory]
[InlineData("Tornado2 TEST", false)]
[InlineData("Tornado2", true)]
[InlineData("Tornado2 PGM", true)]
[InlineData("PROGRAM OUTPUT", true)]
[InlineData("preview pgm backup", true)]
[InlineData("SAFE", false)]
[InlineData("", true)]
[InlineData(null, true)]
public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected)
{
Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(title));
}
[Theory]
[InlineData("Tornado2 TEST", true)]
[InlineData("TEST-1", true)]
[InlineData("preview_test_output", true)]
[InlineData("Tornado2", false)]
[InlineData("CONTEST", false)]
[InlineData("TEST1", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void HasExplicitTestMarker_RequiresStandaloneTestToken(
string? title,
bool expected)
{
Assert.Equal(expected, TornadoProcessProbe.HasExplicitTestMarker(title));
}
[Fact]
public void ClassifyWindowTitles_EditorAndPgmRemainTheSameProgramGeneration_WhenFocusOrderChanges()
{
string[] editorFirst = ["Tornado2 - [5001.t2s]", "PGM"];
string[] pgmFirst = ["PGM", "Tornado2 - [5001.t2s]"];
var first = TornadoProcessProbe.ClassifyWindowTitles(editorFirst, testWindowPattern: null);
var second = TornadoProcessProbe.ClassifyWindowTitles(pgmFirst, testWindowPattern: null);
Assert.True(first.IsProgram);
Assert.True(first.IsEligible);
Assert.Equal(first, second);
var identity = new TornadoProcessIdentity(42, 123456789);
var firstGeneration = TornadoProcessGeneration.FromIdentities(
"program",
first.IsProgram ? [identity] : []);
var secondGeneration = TornadoProcessGeneration.FromIdentities(
"program",
second.IsProgram ? [identity] : []);
Assert.Equal(firstGeneration, secondGeneration);
}
[Fact]
public void ClassifyWindowTitles_TestModeAcceptsExplicitTestTitleFromAnyTopLevelWindow()
{
var pattern = new Regex(
"^Tornado2 TEST$",
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
var classification = TornadoProcessProbe.ClassifyWindowTitles(
["Tornado2 - [test-editor]", "Tornado2 TEST"],
pattern);
Assert.False(classification.IsProgram);
Assert.True(classification.IsEligible);
}
[Fact]
public void ClassifyWindowTitles_TestModeRejectsProcessThatAlsoOwnsPgmWindow()
{
var pattern = new Regex(
"^Tornado2 TEST$",
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
var classification = TornadoProcessProbe.ClassifyWindowTitles(
["Tornado2 TEST", "PGM"],
pattern);
Assert.True(classification.IsProgram);
Assert.False(classification.IsEligible);
}
[Fact]
public void ClassifyWindowTitles_NoMeaningfulTitlesFailsClosedAsProgram()
{
var pattern = new Regex(
"^Tornado2 TEST$",
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
var classification = TornadoProcessProbe.ClassifyWindowTitles(
[null, "", " "],
pattern);
Assert.True(classification.IsProgram);
Assert.False(classification.IsEligible);
}
[Fact]
public void TryClassifyWindowSnapshot_InspectionFailureFailsClosed()
{
var snapshot = new TornadoWindowTitleSnapshot(
["PGM"],
InspectionSucceeded: false);
var succeeded = TornadoProcessProbe.TryClassifyWindowSnapshot(
snapshot,
testWindowPattern: null,
out var classification);
Assert.False(succeeded);
Assert.Equal(default, classification);
}
[Fact]
public void TryClassifyWindowSnapshot_NoMeaningfulTitlesFailsIdentityInspection()
{
var snapshot = new TornadoWindowTitleSnapshot(
["", " "],
InspectionSucceeded: true);
var succeeded = TornadoProcessProbe.TryClassifyWindowSnapshot(
snapshot,
testWindowPattern: null,
out var classification);
Assert.False(succeeded);
Assert.Equal(default, classification);
}
[Fact]
public void TryClassifyWindowSnapshot_PgmTitleSucceedsForLiveIdentityInspection()
{
var snapshot = new TornadoWindowTitleSnapshot(
["Tornado2 - [5001.t2s]", "PGM"],
InspectionSucceeded: true);
var succeeded = TornadoProcessProbe.TryClassifyWindowSnapshot(
snapshot,
testWindowPattern: null,
out var classification);
Assert.True(succeeded);
Assert.True(classification.IsProgram);
Assert.True(classification.IsEligible);
}
[Fact]
public void WindowTitleProbe_InvalidProcessIdFailsClosedWithoutNativeInspection()
{
var snapshot = new Win32TornadoWindowTitleProbe().Capture(0);
Assert.False(snapshot.InspectionSucceeded);
Assert.Empty(snapshot.Titles);
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(2, false)]
[InlineData(10, false)]
public void ProcessSnapshot_RequiresExactlyOneEligibleInstance(
int eligibleProcessCount,
bool expected)
{
var snapshot = new TornadoProcessSnapshot(
TotalProcessCount: eligibleProcessCount,
EligibleProcessCount: eligibleProcessCount,
ProgramProcessCount: 0);
Assert.Equal(expected, snapshot.HasSingleEligibleProcess);
}
[Fact]
public void Validate_TestModeRequiresExplicitOutputChannel()
{
var options = ValidTestOptions();
options.OutputChannel = null;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(nameof(PlayoutOptions.OutputChannel), exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_TestModeRequiresSceneAllowlist()
{
var options = ValidTestOptions();
options.TestSceneAllowlist = [];
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(nameof(PlayoutOptions.TestSceneAllowlist), exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_LiveModeRequiresClosedSceneAllowlist()
{
var options = ValidTestOptions();
options.Mode = PlayoutMode.Live;
options.OutputChannel = null;
options.TestProcessWindowTitlePattern = null;
options.TestSceneAllowlist = [];
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(
nameof(PlayoutOptions.TestSceneAllowlist),
exception.Message,
StringComparison.Ordinal);
}
[Theory]
[InlineData("localhost")]
[InlineData("test-tornado.local")]
[InlineData("192.168.0.10")]
public void Validate_TestModeRequiresLiteralLoopbackAddress(string host)
{
var options = ValidTestOptions();
options.Host = host;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_OrdinaryLiveModeRetainsConfiguredRemoteEndpointBehavior()
{
var options = ValidTestOptions();
options.Mode = PlayoutMode.Live;
options.Host = "playout.dev.invalid";
options.OutputChannel = null;
options.TestProcessWindowTitlePattern = null;
options.TrustedLiveOutputEnabled = true;
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Equal("playout.dev.invalid", validated.Host);
}
[Theory]
[InlineData(".*")]
[InlineData("PGM")]
[InlineData("PROGRAM")]
[InlineData("^Tornado2 (TEST|PGM)$")]
[InlineData("^Tornado2$")]
[InlineData("^$")]
[InlineData("^SAFE$")]
public void Validate_TestWindowPatternThatCanMatchProgramOutput_IsRejected(string pattern)
{
var options = ValidTestOptions();
options.TestProcessWindowTitlePattern = pattern;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains("PROGRAM", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_InvalidTestWindowPattern_IsRejectedWithoutEchoingPattern()
{
const string invalidPattern = "(?<secret>";
var options = ValidTestOptions();
options.TestProcessWindowTitlePattern = invalidPattern;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Equal("테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception.Message);
Assert.DoesNotContain(invalidPattern, exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_TestSceneAllowlistMatchesSceneNameOnlyAndIgnoresCase()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
Assert.True(validated.IsTestSceneAllowed(
new PlayoutCue("test-scene.t2s", "TEST-SCENE")));
Assert.False(validated.IsTestSceneAllowed(
new PlayoutCue("test-scene.t2s", "OTHER-SCENE")));
}
[Fact]
public void ResolveCue_UsesCanonicalFileUnderConfiguredSceneDirectory()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"TEST-SCENE",
FadeDuration: 5));
Assert.Equal(
System.IO.Path.Combine(_scenes.Path, "test-scene.t2s"),
resolved.SceneFile,
ignoreCase: true);
Assert.Equal("TEST-SCENE", resolved.SceneName);
Assert.Equal(5, resolved.FadeDuration);
}
[Theory]
[InlineData("..\\outside.t2s", "outside")]
[InlineData("test-scene.txt", "test-scene")]
[InlineData("test-scene.t2s", "different-name")]
[InlineData("missing.t2s", "missing")]
public void ResolveCue_RejectsUnsafeOrMissingFilesWithoutLeakingInput(
string sceneFile,
string sceneName)
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var exception = Assert.Throws<PlayoutRequestException>(
() => validated.ResolveCue(new PlayoutCue(sceneFile, sceneName)));
Assert.DoesNotContain(sceneFile, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveCue_RejectsRootedScenePathWithoutLeakingIt()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var rootedPath = System.IO.Path.Combine(_scenes.Path, "test-scene.t2s");
var exception = Assert.Throws<PlayoutRequestException>(
() => validated.ResolveCue(new PlayoutCue(rootedPath, "test-scene")));
Assert.Equal("장면 파일 이름이 올바르지 않습니다.", exception.Message);
Assert.DoesNotContain(rootedPath, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveCue_CanonicalizesAllowlistedBackgroundTextureUnderSceneRoot()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations: [new PlayoutSetBackgroundTexture("Video/background.vrv")]));
var texture = Assert.IsType<PlayoutSetBackgroundTexture>(Assert.Single(resolved.Mutations!));
Assert.Equal(
Path.Combine(_scenes.Path, "Video", "background.vrv"),
texture.AssetPath,
ignoreCase: true);
}
[Fact]
public void ResolveCue_OperatorBackgroundUsesOnlyTheSeparateTrustedRoot()
{
using var backgrounds = TemporarySceneDirectory.Create("studio.vrv");
var options = ValidTestOptions();
options.LegacyBackgroundDirectory = backgrounds.Path;
var validated = ValidatedPlayoutOptions.Create(options);
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"studio.vrv",
LoopCount: 2004,
LoopInfinite: 10,
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
]));
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
Assert.Equal(
Path.Combine(backgrounds.Path, "studio.vrv"),
video.AssetPath,
ignoreCase: true);
Assert.Equal(PlayoutAssetRoot.OperatorBackgroundDirectory, video.AssetRoot);
}
[Fact]
public void ResolveCue_OperatorBackgroundDoesNotFallBackToCutsRoot()
{
using var backgrounds = TemporarySceneDirectory.Create();
var options = ValidTestOptions();
options.LegacyBackgroundDirectory = backgrounds.Path;
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Throws<PlayoutRequestException>(() => validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"Video\\background.vrv",
LoopCount: 2004,
LoopInfinite: 10,
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
])));
}
[Fact]
public void ResolveCue_RevalidatesConfiguredBackgroundRootAtCommandTime()
{
var lateRoot = Path.Combine(Directory.GetParent(_scenes.Path)!.FullName, "late-background");
var options = ValidTestOptions();
options.LegacyBackgroundDirectory = lateRoot;
var validated = ValidatedPlayoutOptions.Create(options);
Directory.CreateDirectory(lateRoot);
File.WriteAllText(Path.Combine(lateRoot, "studio.vrv"), "late trusted background");
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"studio.vrv",
LoopCount: 2004,
LoopInfinite: 10,
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
]));
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
Assert.Equal(
Path.Combine(lateRoot, "studio.vrv"),
video.AssetPath,
ignoreCase: true);
}
[Fact]
public void ResolveCue_AcceptsTheLegacy2004BackgroundVideoLoopValue()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"Video/background.vrv",
LoopCount: 2004,
LoopInfinite: 10)
]));
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
Assert.Equal(2004, video.LoopCount);
Assert.Equal(
Path.Combine(_scenes.Path, "Video", "background.vrv"),
video.AssetPath,
ignoreCase: true);
}
[Theory]
[InlineData(-1)]
[InlineData(2)]
[InlineData(9)]
[InlineData(11)]
public void ResolveCue_RejectsBackgroundVideoLoopModesNotUsedByMainForm(int loopMode)
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
Assert.Throws<PlayoutRequestException>(() => validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"Video/background.vrv",
LoopCount: 2004,
LoopInfinite: loopMode)
])));
}
[Theory]
[InlineData("../outside.vrv")]
[InlineData("Video/missing.vrv")]
[InlineData("Video/background.exe")]
public void ResolveCue_RejectsUnsafeBackgroundTextureWithoutLeakingInput(string assetPath)
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var exception = Assert.Throws<PlayoutRequestException>(() =>
validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations: [new PlayoutSetBackgroundTexture(assetPath)])));
Assert.DoesNotContain(assetPath, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(0, 5000, 64, nameof(PlayoutOptions.Port))]
[InlineData(30001, 99, 64, nameof(PlayoutOptions.OperationTimeoutMilliseconds))]
[InlineData(30001, 5000, 0, nameof(PlayoutOptions.QueueCapacity))]
public void Validate_OutOfRangeValuesAreRejected(
int port,
int operationTimeoutMilliseconds,
int queueCapacity,
string expectedProperty)
{
var options = ValidTestOptions();
options.Port = port;
options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds;
options.QueueCapacity = queueCapacity;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData(-1)]
[InlineData(1_000_001)]
public void Validate_OutOfRangeAutomaticRefreshMaximumIsRejected(int value)
{
var options = ValidTestOptions();
options.MaximumAutomaticRefreshesPerTakeIn = value;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(
nameof(PlayoutOptions.MaximumAutomaticRefreshesPerTakeIn),
exception.Message,
StringComparison.Ordinal);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(1_000_000)]
public void Validate_AutomaticRefreshMaximumAcceptsClosedSafeRange(int value)
{
var options = ValidTestOptions();
options.MaximumAutomaticRefreshesPerTakeIn = value;
_ = ValidatedPlayoutOptions.Create(options);
}
public void Dispose() => _scenes.Dispose();
private PlayoutOptions ValidTestOptions() => new()
{
Mode = PlayoutMode.Test,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
ClientPort = 0,
OutputChannel = 9,
LayoutIndex = 10,
SceneDirectory = _scenes.Path,
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
TestSceneAllowlist = ["test-scene"],
TrustedLiveOutputEnabled = false,
QueueCapacity = 64,
ConnectTimeoutMilliseconds = 5000,
OperationTimeoutMilliseconds = 5000,
DisconnectTimeoutMilliseconds = 3000,
ProcessPollIntervalMilliseconds = 1000,
ReconnectDelayMilliseconds = 1000,
MaximumReconnectAttempts = 3,
ReconnectEnabled = true
};
}