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( () => 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( () => 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( () => 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( () => ValidatedPlayoutOptions.Create(options)); Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal); } [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( () => ValidatedPlayoutOptions.Create(options)); Assert.Contains("PROGRAM", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Validate_InvalidTestWindowPattern_IsRejectedWithoutEchoingPattern() { const string invalidPattern = "(?"; var options = ValidTestOptions(); options.TestProcessWindowTitlePattern = invalidPattern; var exception = Assert.Throws( () => 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( () => 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( () => 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(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: true, AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory) ])); var video = Assert.IsType(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(() => validated.ResolveCue(new PlayoutCue( "test-scene.t2s", "test-scene", Mutations: [ new PlayoutSetBackgroundVideo( "Video\\background.vrv", LoopCount: 2004, LoopInfinite: true, 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: true, AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory) ])); var video = Assert.IsType(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: true) ])); var video = Assert.IsType(Assert.Single(resolved.Mutations!)); Assert.Equal(2004, video.LoopCount); Assert.Equal( Path.Combine(_scenes.Path, "Video", "background.vrv"), video.AssetPath, ignoreCase: true); } [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(() => 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( () => ValidatedPlayoutOptions.Create(options)); Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal); } 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 }; }