328 lines
11 KiB
C#
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
|
|
};
|
|
}
|