using System.Net; using Ludarium.Api; using Ludarium.Application; using Microsoft.AspNetCore.Http; namespace Ludarium.E2E; /// /// The shared transport behind every isolated native remote player. These assertions previously /// existed once per emulator; keeping them here is what guarantees a new player inherits the same /// pinned TLS, opaque capability, cookie scope and revocation behaviour. /// public sealed class NativeRemotePlayerTests { private const string CertificateHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; private static NativeRemotePlayer Switch => NativeRemotePlayerRegistry.Switch; private static NativeRemotePlayer Dolphin => NativeRemotePlayerRegistry.Dolphin; [Theory] [InlineData("switch", "eden", "/switch-player", "ludarium-switch-session", 1231)] [InlineData("dolphin", "dolphin", "/dolphin-player", "ludarium-dolphin-session", 1232)] public void RegistryDrivesEveryPublicIdentifier(string key, string host, string proxyPath, string cookie, int port) { var player = NativeRemotePlayerRegistry.ForKey(key)!; Assert.Equal(host, player.ProxyHost); Assert.Equal(proxyPath, player.ProxyPath); Assert.Equal(cookie, player.CookieName); Assert.Equal(port, player.DefaultPublicPort); Assert.Equal($"{key}-player-sessions", player.SessionRoute); } [Fact] public void EveryNativePlatformResolvesToExactlyOnePlayer() { Assert.Same(Switch, NativeRemotePlayerRegistry.ForPlatform("switch")); Assert.Same(Dolphin, NativeRemotePlayerRegistry.ForPlatform("GameCube")); Assert.Same(Dolphin, NativeRemotePlayerRegistry.ForPlatform("wii")); Assert.Null(NativeRemotePlayerRegistry.ForPlatform("ps2")); var owners = NativeRemotePlayerRegistry.Platforms .Select(platform => NativeRemotePlayerRegistry.All.Count(player => player.Owns(platform))); Assert.All(owners, count => Assert.Equal(1, count)); } [Fact] public void AGameNamingTwoNativePlatformsIsDeliberatelyUnresolvable() { Assert.Equal("wii", NativeRemotePlayerRegistry.ResolvePlatform(["wii", "wii", null])); Assert.Null(NativeRemotePlayerRegistry.ResolvePlatform(["wii", "gamecube"])); Assert.Null(NativeRemotePlayerRegistry.ResolvePlatform(["windows", "ps2"])); } [Fact] public void ProxyRequiresHttpsPinnedCertificateServerSideCredentialsAndBoundedPort() { Assert.False(Options(Switch, "http://eden:3001/").Configured); Assert.False(Options(Switch, "https://eden:3001/", username: null).Configured); Assert.False(Options(Switch, "https://eden:3001/", certificate: null).Configured); Assert.False(Options(Switch, "https://eden:3001/path").Configured); Assert.True(Options(Switch, "https://eden:3001/").Configured); } [Fact] public void ProxyOnlyAcceptsItsOwnInternalSidecarHost() { Assert.False(Options(Dolphin, "https://other:3001/").Configured); Assert.False(Options(Dolphin, "https://eden:3001/").Configured); Assert.True(Options(Dolphin, "https://dolphin:3001/").Configured); } [Fact] public void LaunchOriginUsesTheRequestHostAndDedicatedPort() { var options = Options(Switch, "https://eden:3001/", port: 4321); var request = new DefaultHttpContext().Request; request.Scheme = "https"; request.Host = new HostString("games.example.test", 1230); Assert.Equal("https://games.example.test:4321/switch-player/", options.BuildLaunchUrl(request)); } [Fact] public void PublicPlayerOriginMustBeHttpsAndAChildOfTheRequestHost() { var request = new DefaultHttpContext().Request; request.Scheme = "http"; request.Host = new HostString("ludarium.example"); var options = Options(Switch, "https://eden:3001/", port: 4321, origin: "https://player.ludarium.example/"); Assert.Equal("https://player.ludarium.example/switch-player/", options.BuildLaunchUrl(request)); Assert.Equal("https://player.ludarium.example/switch-player/", options.BuildExternalUrl(request)); Assert.Equal("ludarium.example", options.CookieDomain(request)); Assert.True(options.AcceptsProxyRequest(RequestFor("player.ludarium.example"))); Assert.False(options.AcceptsProxyRequest(RequestFor("ludarium.example"))); Assert.Null(Options(Switch, "https://eden:3001/", origin: "http://player.ludarium.example/").PublicOrigin); Assert.Throws(() => Options(Switch, "https://eden:3001/", origin: "https://player.example.net/").BuildLaunchUrl(request)); } [Fact] public void DolphinPublicOriginMustBeAChildOfTheMainHost() { var options = Options(Dolphin, "https://dolphin:3001/", origin: "https://dolphin.ludarium.test/"); var request = new DefaultHttpContext().Request; request.Scheme = "https"; request.Host = new HostString("ludarium.test"); Assert.Equal("https://dolphin.ludarium.test/dolphin-player/", options.BuildLaunchUrl(request)); } [Fact] public void SessionsAreOpaqueBoundedExpiringAndExplicitlyEnded() { var time = new MutableTimeProvider(new DateTimeOffset(2026, 8, 11, 20, 0, 0, TimeSpan.Zero)); var sessions = new NativeRemotePlayerSessionStore(Switch, time); var first = sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null); var second = sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null); Assert.True(sessions.Authorizes(first.Token)); Assert.False(sessions.Authorizes(first.Token + "changed")); Assert.Throws(() => sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null)); Assert.False(sessions.End(Guid.NewGuid())); Assert.True(sessions.End(first.Session.Id)); Assert.False(sessions.Authorizes(first.Token)); time.Advance(NativeRemotePlayerSessionStore.SessionLifetime.Add(TimeSpan.FromSeconds(1))); Assert.False(sessions.Authorizes(second.Token)); Assert.False(sessions.HasActiveSession); Assert.NotNull(sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null)); } [Fact] public void CapacityFollowsThePlayerDescription() { var sessions = new NativeRemotePlayerSessionStore(Dolphin, TimeProvider.System); var ticket = sessions.Create(Guid.NewGuid(), "https://ludarium.test:1232/dolphin-player/", null, "wii"); Assert.Equal("Dolphin", ticket.Session.Emulator); Assert.Equal("wii", ticket.Session.Platform); Assert.True(ticket.Session.ExactGameLaunch); Assert.Equal("native-and-savestate-persistent", ticket.Session.SaveMode); Assert.Throws(() => sessions.Create(Guid.NewGuid(), "https://ludarium.test:1232/dolphin-player/", null, "wii")); Assert.True(sessions.End(ticket.Session.Id)); Assert.False(sessions.Authorizes(ticket.Token)); } [Fact] public void SessionCookieIsHttpOnlyProxyScopedAndStrict() { var sessions = new NativeRemotePlayerSessionStore(Switch, TimeProvider.System); var cookie = sessions.Cookie(DateTimeOffset.UtcNow.AddHours(1), secure: true); Assert.True(cookie.HttpOnly); Assert.True(cookie.Secure); Assert.Equal(SameSiteMode.Strict, cookie.SameSite); Assert.Equal("/switch-player", cookie.Path); Assert.Null(cookie.Domain); Assert.Equal(DateTimeOffset.UnixEpoch, sessions.ExpiredCookie(true).Expires); } [Theory] [InlineData(null, false)] [InlineData("not-a-hash", false)] [InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", true)] public void RuntimeQualificationRequiresAnExactFixtureDigest(string? digest, bool expected) => Assert.Equal(expected, NativeRemotePlayerControlClient.IsValidSha256(digest)); [Fact] public void ControlConfigurationOnlyAcceptsTheInternalSidecarOrigin() { Assert.True(Control(Switch, "http://eden:8765/", "secret").Configured); Assert.False(Control(Switch, "https://eden:8765/", "secret").Configured); Assert.False(Control(Switch, "http://other:8765/", "secret").Configured); Assert.False(Control(Switch, "http://eden:8765/control/", "secret").Configured); Assert.False(Control(Switch, "http://eden:8765/", null).Configured); } [Fact] public void APlayerThatRequiresAFixtureStaysUnconfiguredWithoutOne() { Assert.False(Control(Dolphin, "http://dolphin:8765/", "secret").Configured); Assert.True(Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash).Configured); // Switch has no fixture gate, so a digest is neither required nor consulted. Assert.True(Control(Switch, "http://eden:8765/", "secret").Configured); } [Fact] public async Task LaunchUsesTheAuthenticatedFixedControllerEndpoint() { Uri? observedUri = null; string? observedToken = null; string? observedBody = null; var control = Control(Switch, "http://eden:8765/", "separate-secret", handler: request => { observedUri = request.RequestUri; observedToken = request.Headers.GetValues("X-Ludarium-Control-Token").Single(); observedBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); return new HttpResponseMessage(HttpStatusCode.Accepted); }); await control.LaunchAsync("Animal Crossing/base.xci", CancellationToken.None); Assert.Equal(new Uri("http://eden:8765/v1/launch"), observedUri); Assert.Equal("separate-secret", observedToken); Assert.Contains("Animal Crossing/base.xci", observedBody); } [Fact] public void CatalogPrefixVariablesMatchTheDeployedConfiguration() { Assert.Equal("CATALOG_PREFIX", NativeRemotePlayerControlClient.CatalogPrefixVariable(Switch, "switch")); Assert.Equal("GAMECUBE_CATALOG_PREFIX", NativeRemotePlayerControlClient.CatalogPrefixVariable(Dolphin, "gamecube")); Assert.Equal("WII_CATALOG_PREFIX", NativeRemotePlayerControlClient.CatalogPrefixVariable(Dolphin, "wii")); Assert.Equal("LUDARIUM_SWITCH", Switch.EnvironmentPrefix); Assert.Equal("LUDARIUM_DOLPHIN", Dolphin.EnvironmentPrefix); } [Fact] public async Task AConfiguredButUnreachableRuntimeIsNotReported() { var control = Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash, _ => throw new HttpRequestException("connection refused")); var status = await control.GetStatusAsync(CancellationToken.None); Assert.False(status.Reachable); Assert.False(status.Running); Assert.False(status.SaveDataSupported); } [Fact] public async Task ALiveRuntimeReportsItsRunningTitleAndSaveDataSupport() { var control = Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash, _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """{"running":true,"pid":42,"saveMode":"native-and-savestate-persistent","saveData":true}""") }); var status = await control.GetStatusAsync(CancellationToken.None); Assert.True(status.Reachable); Assert.True(status.Running); Assert.True(status.SaveDataSupported); Assert.Equal("native-and-savestate-persistent", status.SaveMode); } [Fact] public async Task AHangingRuntimeCannotStallACapabilityRead() { var control = Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash, _ => { // A sidecar that accepts the connection and never answers must not hold up a catalog page. Thread.Sleep(NativeRemotePlayerControlClient.HealthProbeTimeout + TimeSpan.FromSeconds(3)); return new HttpResponseMessage(HttpStatusCode.OK); }); var started = System.Diagnostics.Stopwatch.StartNew(); var status = await control.GetStatusAsync(CancellationToken.None); started.Stop(); Assert.False(status.Reachable); Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"probe took {started.Elapsed}"); } [Fact] public async Task AnUnconfiguredRuntimeIsNeverProbed() { var probes = 0; var control = Control(Dolphin, "http://dolphin:8765/", "secret", fixture: null, handler: _ => { probes++; return new HttpResponseMessage(HttpStatusCode.OK); }); Assert.False((await control.GetStatusAsync(CancellationToken.None)).Reachable); Assert.Equal(0, probes); } [Fact] public async Task EndingASessionStopsTheTitleBestEffort() { var actions = new List(); var control = Control(Switch, "http://eden:8765/", "secret", handler: request => { actions.Add(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); return new HttpResponseMessage(HttpStatusCode.Accepted); }); Assert.True(await control.TryStopAsync(CancellationToken.None)); Assert.Contains("stop", Assert.Single(actions)); } [Fact] public async Task AFailingStopNeverBreaksSessionRevocation() { var control = Control(Switch, "http://eden:8765/", "secret", handler: _ => new HttpResponseMessage(HttpStatusCode.Conflict)); Assert.False(await control.TryStopAsync(CancellationToken.None)); } [Fact] public void EveryPlayerActionIsAllowlisted() { Assert.Contains("stop", Switch.Actions); Assert.DoesNotContain("save-state", Switch.Actions); Assert.False(Switch.SupportsSavestates); Assert.Contains("save-state", Dolphin.Actions); Assert.Contains("load-state", Dolphin.Actions); Assert.True(Dolphin.SupportsSavestates); } private static NativeRemotePlayerProxyOptions Options(NativeRemotePlayer player, string destination, string? username = "user", string? certificate = CertificateHash, int? port = null, string? origin = null) => new(player, new Uri(destination), null, username, "pass", port, certificate, origin is null ? null : new Uri(origin)); private static NativeRemotePlayerControlClient Control(NativeRemotePlayer player, string address, string? token, string? fixture = null, Func? handler = null) => new(player, new HttpClient(new StubHandler(handler ?? (_ => new HttpResponseMessage(HttpStatusCode.Accepted)))) { BaseAddress = new Uri(address) }, token, player.Platforms.ToDictionary(platform => platform, platform => $"roms/{platform}"), fixture); private static HttpRequest RequestFor(string host) { var request = new DefaultHttpContext().Request; request.Host = new HostString(host); return request; } private sealed class MutableTimeProvider(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; public void Advance(TimeSpan amount) => now = now.Add(amount); } private sealed class StubHandler(Func response) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(response(request)); } }