feat: add safe Tornado K3D playout adapter

This commit is contained in:
2026-07-10 06:41:48 +09:00
parent 39c4504b87
commit 5a8c7028dc
43 changed files with 7341 additions and 92 deletions

View File

@@ -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
};
}