feat: add safe Tornado K3D playout adapter
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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 PGM", true)]
|
||||
[InlineData("PROGRAM OUTPUT", true)]
|
||||
[InlineData("preview pgm backup", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(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);
|
||||
}
|
||||
|
||||
[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)$")]
|
||||
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);
|
||||
}
|
||||
|
||||
[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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user