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

328 lines
11 KiB
C#

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", "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));
}
[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);
}
[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_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<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("../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);
}
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
};
}