using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Runtime; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class PlayoutSafetyValidationTests : IDisposable { private readonly TemporarySceneDirectory _scenes = TemporarySceneDirectory.Create("test-scene.t2s"); [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)); } [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); } [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); } [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 }; }