using System.Security; using System.Text; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Diagnostics; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class DevelopmentLiveLaunchBootstrapTests { private const string NativeSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private const string InteropSha256 = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; [Theory] [InlineData("--development-live", "ignored", "--development-live")] [InlineData("unexpected", "--development-live", "unexpected")] [InlineData(" ", "--development-live", " ")] public void ResolveLaunchArguments_PreservesNonEmptyActivationArguments( string activationArguments, string processArgument, string expected) { Assert.Equal( expected, DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( activationArguments, ["app.exe", processArgument])); } [Theory] [InlineData(null)] [InlineData("")] public void ResolveLaunchArguments_UsesOneProcessArgumentWhenActivationIsEmpty( string? activationArguments) { Assert.Equal( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( activationArguments, ["app.exe", DevelopmentLiveLaunchBootstrap.ExactLaunchArgument])); } [Fact] public void ResolveLaunchArguments_RejectsMissingOrAdditionalProcessArguments() { Assert.Null(DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments("", ["app.exe"])); Assert.Null(DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( "", ["app.exe", DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, "extra"])); } [Fact] public void DefaultPath_IsASeparateLocalApplicationDataFile() { var expected = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", "Config", "playout.development-live.local.json"); Assert.Equal(expected, DevelopmentLiveLaunchBootstrap.DefaultPath); Assert.NotEqual(PlayoutOptionsLoader.DefaultPath, DevelopmentLiveLaunchBootstrap.DefaultPath); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("--development-live ")] [InlineData(" --development-live")] [InlineData("--DEVELOPMENT-LIVE")] [InlineData("--development-live --another-option")] public void NonExactArgument_SkipsWithoutReadingOrMutating(string? arguments) { var platform = FakePlatform.Valid(); var result = DevelopmentLiveLaunchBootstrap.TryApply( arguments, isDebugBuild: true, "ignored.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Skipped, result.Status); Assert.Equal("development-live-not-requested", result.Code); Assert.Equal(0, platform.ReadCount); Assert.Empty(platform.Environment); } [Fact] public void ReleaseBuild_SkipsExactArgumentWithoutReadingOrMutating() { var platform = FakePlatform.Valid(); var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: false, "ignored.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Skipped, result.Status); Assert.Equal("development-live-debug-only", result.Code); Assert.Equal(0, platform.ReadCount); Assert.Empty(platform.Environment); } [Fact] public void DebugExactArgumentAndStrictFile_ApplyAllProcessGates() { var platform = FakePlatform.Valid(); var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: true, "authorization.json", platform); Assert.True(result.IsApplied); Assert.Equal(DevelopmentLiveBootstrapStatus.Applied, result.Status); Assert.Equal("development-live-applied", result.Code); Assert.Equal(1, platform.ReadCount); Assert.Equal( NativeSha256.ToUpperInvariant(), platform.Environment[ InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable]); Assert.Equal( InteropSha256, platform.Environment[ InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable]); Assert.Equal( DevelopmentLiveLaunchBootstrap.RequiredMode, platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable]); Assert.Equal( PlayoutOptionsLoader.RequiredLiveAuthorizationValue, platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable]); } [Theory] [MemberData(nameof(InvalidAuthorizationJson))] public void InvalidOrNonCanonicalAuthorization_IsRejectedWithoutEnvironmentMutation( string json) { var platform = new FakePlatform(Encoding.UTF8.GetBytes(json)); var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: true, "authorization.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-authorization-invalid", result.Code); Assert.Empty(platform.Environment); } [Fact] public void OversizedAuthorization_IsRejectedBeforeParsing() { var platform = new FakePlatform( new byte[DevelopmentLiveLaunchBootstrap.MaximumAuthorizationFileBytes + 1]); var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: true, "authorization.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-authorization-invalid", result.Code); Assert.Empty(platform.Environment); } [Fact] public void PermissionFailure_IsFailClosedAndDoesNotLeakDetails() { var platform = FakePlatform.Valid(); platform.ReadException = new UnauthorizedAccessException("sensitive local path"); var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: true, "sensitive local path", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-authorization-unavailable", result.Code); Assert.DoesNotContain("sensitive", result.Code, StringComparison.OrdinalIgnoreCase); Assert.Empty(platform.Environment); } [Fact] public void EnvironmentFailure_DisarmsEveryPreviousProcessValue() { var platform = FakePlatform.Valid(); platform.Environment[ InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable] = "old-native"; platform.Environment[ InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable] = "old-interop"; platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable] = "DryRun"; platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable] = "old-authorization"; platform.FailOnceOnSetName = DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable; var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: true, "authorization.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-environment-failed", result.Code); Assert.Empty(platform.Environment); } [Fact] public void RejectedExactDebugRequest_DisarmsInheritedLiveGates() { var platform = new FakePlatform(Encoding.UTF8.GetBytes("{}")); platform.Environment[ InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable] = new string('A', 64); platform.Environment[ InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable] = new string('B', 64); platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable] = DevelopmentLiveLaunchBootstrap.RequiredMode; platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable] = PlayoutOptionsLoader.RequiredLiveAuthorizationValue; var result = DevelopmentLiveLaunchBootstrap.TryApply( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, isDebugBuild: true, "authorization.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Empty(platform.Environment); } public static TheoryData InvalidAuthorizationJson() => new() { "{}", ValidJson().Replace("\"schemaVersion\": 1", "\"schemaVersion\": 2"), ValidJson().Replace("\"mode\": \"Live\"", "\"mode\": \"live\""), ValidJson().Replace( PlayoutOptionsLoader.RequiredLiveAuthorizationValue, "I_AUTHORIZE_SOMETHING_ELSE"), ValidJson().Replace(NativeSha256, new string('A', 63)), ValidJson().Replace(InteropSha256, new string('G', 64)), ValidJson().Replace( "\"interopSha256\"", "\"unexpected\": true, \"interopSha256\""), ValidJson().Replace( "\"mode\": \"Live\"", "\"mode\": \"Live\", \"mode\": \"Live\""), ValidJson().Replace("\"mode\"", "\"Mode\""), ValidJson().TrimEnd('}') + ",}", ValidJson().Replace("{", "{/* comment */", StringComparison.Ordinal), "[]", "null" }; private static string ValidJson() => $$""" { "schemaVersion": 1, "mode": "Live", "authorization": "{{PlayoutOptionsLoader.RequiredLiveAuthorizationValue}}", "nativeSha256": "{{NativeSha256}}", "interopSha256": "{{InteropSha256}}" } """; private sealed class FakePlatform(byte[] authorizationBytes) : IDevelopmentLiveBootstrapPlatform { public Dictionary Environment { get; } = new(StringComparer.Ordinal); public Exception? ReadException { get; set; } public string? FailOnceOnSetName { get; set; } public int ReadCount { get; private set; } public static FakePlatform Valid() => new(Encoding.UTF8.GetBytes(ValidJson())); public byte[] ReadAuthorizationFile(string path, int maximumBytes) { ReadCount++; if (ReadException is not null) { throw ReadException; } return authorizationBytes; } public string? GetProcessEnvironmentVariable(string name) => Environment.GetValueOrDefault(name); public void SetProcessEnvironmentVariable(string name, string? value) { if (string.Equals(name, FailOnceOnSetName, StringComparison.Ordinal)) { FailOnceOnSetName = null; throw new SecurityException("simulated environment denial"); } if (value is null) { Environment.Remove(name); } else { Environment[name] = value; } } } }