This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
using System.Net;
|
||||
using Ludarium.Api;
|
||||
using Ludarium.Application;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<NativeRemotePlayerCapacityException>(() =>
|
||||
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<NativeRemotePlayerCapacityException>(() =>
|
||||
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<string>();
|
||||
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<HttpRequestMessage, HttpResponseMessage>? 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<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(response(request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user