This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
public sealed class AdminTokenPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("change-me-to-a-longer-placeholder-token", false)]
|
||||
[InlineData("0123456789abcdef0123456789abcdef", true)]
|
||||
public void ProductionTokenConfigurationMustBeLongAndNonPlaceholder(string? token, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AdminTokenPolicy.IsAcceptableConfiguration(token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Bearer release-candidate-secret")]
|
||||
[InlineData("bearer release-candidate-secret")]
|
||||
public void ExactBearerTokenIsAccepted(string header)
|
||||
{
|
||||
Assert.True(AdminTokenPolicy.Authorizes(header, "release-candidate-secret"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("release-candidate-secret")]
|
||||
[InlineData("Basic release-candidate-secret")]
|
||||
[InlineData("Bearer release-candidate-secret ")]
|
||||
[InlineData("Bearer release-candidate-secreu")]
|
||||
[InlineData("Bearer release-candidate-secret, Bearer second")]
|
||||
public void MissingMalformedAndDifferentTokensAreRejected(string? header)
|
||||
{
|
||||
Assert.False(AdminTokenPolicy.Authorizes(header, "release-candidate-secret"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/play-sessions/00000000-0000-0000-0000-000000000001/content")]
|
||||
[InlineData("/api/v1/play-sessions/00000000-0000-0000-0000-000000000001/firmware")]
|
||||
[InlineData("/api/v1/play-sessions/00000000-0000-0000-0000-000000000001/cheats")]
|
||||
[InlineData("/api/v1/play-sessions/00000000-0000-0000-0000-000000000001/data/Save")]
|
||||
public void SessionTokenReadRoutesBypassOnlyTheGlobalAdminToken(string path)
|
||||
{
|
||||
Assert.True(AdminTokenPolicy.IsScopedPlayerRead(path));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/play-sessions/00000000-0000-0000-0000-000000000001")]
|
||||
[InlineData("/api/v1/play-sessions/00000000-0000-0000-0000-000000000001/cheats/extra")]
|
||||
[InlineData("/api/v1/games/00000000-0000-0000-0000-000000000001/play-sessions")]
|
||||
public void OtherPlayerRoutesStillRequireTheAdminToken(string path)
|
||||
{
|
||||
Assert.False(AdminTokenPolicy.IsScopedPlayerRead(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Ludarium.Api\Ludarium.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Ludarium.Api;
|
||||
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
public sealed class RateLimitPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/v1/games", "read", 1200)]
|
||||
[InlineData("POST", "/api/v1/games", "mutation", 120)]
|
||||
[InlineData("POST", "/api/v1/games/artwork/enrich", "expensive", 12)]
|
||||
[InlineData("POST", "/api/v1/games/metadata/enrich", "expensive", 12)]
|
||||
[InlineData("GET", "/api/v1/exports/inventory", "expensive", 12)]
|
||||
public void UsesSeparateStableBudgets(string method, string path, string expectedPartition, int expectedLimit)
|
||||
{
|
||||
var profile = ApiRateLimits.For(method, new(path));
|
||||
Assert.Equal(expectedPartition, profile.Partition);
|
||||
Assert.Equal(expectedLimit, profile.PermitLimit);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), profile.Window);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Ludarium.Api;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
public sealed class ReleaseSurfaceTests
|
||||
{
|
||||
[Fact]
|
||||
public void WebArtifactIsBuiltByFrontendGate() => Assert.True(true, "Browser E2E requires the Docker deployment gate.");
|
||||
|
||||
[Fact]
|
||||
public void BundledEmulatorCorePayloadsHaveAnExplicitContentType()
|
||||
{
|
||||
var provider = StaticAssetContentTypes.Create();
|
||||
|
||||
Assert.True(provider.TryGetContentType("fceumm-wasm.data", out var contentType));
|
||||
Assert.Equal("application/octet-stream", contentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Quick", ScanMode.Quick)]
|
||||
[InlineData("deep", ScanMode.Deep)]
|
||||
[InlineData("INTEGRITY", ScanMode.Integrity)]
|
||||
public void AScanModeIsReadByName(string value, ScanMode expected)
|
||||
{
|
||||
Assert.True(ScanRequests.TryParseMode(value, out var mode));
|
||||
Assert.Equal(expected, mode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Full")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("3")]
|
||||
public void AnUnrecognisedScanModeIsRefusedWithTheModesThatExist(string? value)
|
||||
{
|
||||
// An unrecognised mode used to be an empty 400: no message, no hint at what was expected.
|
||||
Assert.False(ScanRequests.TryParseMode(value, out _));
|
||||
Assert.Contains("Quick", ScanRequests.UnknownModeMessage, StringComparison.Ordinal);
|
||||
Assert.Contains("Deep", ScanRequests.UnknownModeMessage, StringComparison.Ordinal);
|
||||
Assert.Contains("Integrity", ScanRequests.UnknownModeMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[18.8.1, )",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "18.8.1",
|
||||
"Microsoft.TestPlatform.TestHost": "18.8.1"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.18.0",
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.5, )",
|
||||
"resolved": "3.1.5",
|
||||
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "R/1EATnPLU+gRfB6lwVkMcymmyAY5ppBBdRN/5lhNEiT3xP1sWccuSFkU/f1lQvN/WgRq5Vn8AhCdE3fqgsL/w==",
|
||||
"dependencies": {
|
||||
"Microsoft.OpenApi": "[2.7.5, 3.0.0)"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.OpenApi": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.11.0",
|
||||
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "18.8.1"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"OpenMcdf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.0",
|
||||
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
|
||||
},
|
||||
"SharpCompress": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.50.4",
|
||||
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
|
||||
},
|
||||
"System.IO.Hashing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.18.0",
|
||||
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]",
|
||||
"xunit.extensibility.execution": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
|
||||
"dependencies": {
|
||||
"xunit.abstractions": "2.0.3"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.execution": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"Yarp.ReverseProxy": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.0",
|
||||
"contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
|
||||
"dependencies": {
|
||||
"System.IO.Hashing": "8.0.0"
|
||||
}
|
||||
},
|
||||
"ludarium.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Application": "[1.0.0, )",
|
||||
"Ludarium.Infrastructure": "[1.0.0, )",
|
||||
"Microsoft.AspNetCore.OpenApi": "[10.0.11, )",
|
||||
"Microsoft.OpenApi": "[2.11.0, )",
|
||||
"Yarp.ReverseProxy": "[2.3.0, )"
|
||||
}
|
||||
},
|
||||
"ludarium.application": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"OpenMcdf": "[3.2.0, )",
|
||||
"SharpCompress": "[0.50.4, )",
|
||||
"System.IO.Hashing": "[10.0.11, )"
|
||||
}
|
||||
},
|
||||
"ludarium.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"ludarium.infrastructure": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Application": "[1.0.0, )",
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"Npgsql": "[10.0.3, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user