using System.Security; using System.Text; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Diagnostics; using MBN_STOCK_WEBVIEW.Playout.Safety; 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_DistinguishesNormalLaunchFromAdditionalArguments() { Assert.Null(DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments("", ["app.exe"])); var additionalArguments = DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( "", ["app.exe", DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, "extra"]); Assert.NotNull(additionalArguments); Assert.NotEqual(string.Empty, additionalArguments); Assert.NotEqual( DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, additionalArguments); } [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("--development-live ")] [InlineData(" --development-live")] [InlineData("--DEVELOPMENT-LIVE")] [InlineData("--development-live --another-option")] public void NonExactArgument_IsRejectedWithoutReadingOrMutating(string? arguments) { var platform = FakePlatform.Valid(); var result = DevelopmentLiveLaunchBootstrap.TryApply( arguments, isDebugBuild: true, "ignored.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-arguments-invalid", result.Code); Assert.False(result.IsLaunchAllowed); Assert.True(result.IsRequested); Assert.Equal(0, platform.ReadCount); Assert.Empty(platform.Environment); } [Theory] [InlineData(true)] [InlineData(false)] public void GateALaunch_BypassesOrdinaryAuthorizationWithoutEnvironmentMutation( bool isDebugBuild) { var platform = FakePlatform.Valid(); platform.Environment[ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable] = new string('A', 64); var originalEnvironment = platform.Environment.ToArray(); var result = DevelopmentLiveLaunchBootstrap.TryApply( null, isDebugBuild, "must-not-be-read.json", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.GateABypassed, result.Status); Assert.Equal("development-live-gate-a-bypassed", result.Code); Assert.True(result.IsLaunchAllowed); Assert.False(result.IsRequested); Assert.Equal(0, platform.ReadCount); Assert.Equal(originalEnvironment, platform.Environment); } [Theory] [InlineData(null, true)] [InlineData("", true)] [InlineData("--development-live", true)] [InlineData(null, false)] [InlineData("", false)] [InlineData("--development-live", false)] public void NormalOrExactLaunchAndStrictFile_ApplyAllProcessGates( string? arguments, bool isDebugBuild) { var platform = FakePlatform.Valid(); var result = DevelopmentLiveLaunchBootstrap.TryApply( arguments, isDebugBuild, "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 InvalidAuthorizationFileRead_IsUnavailableAndDoesNotMutateEnvironment() { var platform = FakePlatform.Valid(); platform.ReadException = new InvalidDataException("sensitive authorization bytes"); var result = DevelopmentLiveLaunchBootstrap.TryApply( null, isDebugBuild: false, "sensitive authorization path", platform); Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-authorization-unavailable", result.Code); Assert.False(result.IsLaunchAllowed); 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] = "stale-mode"; 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; } } } }