using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; using System.Reflection; namespace Ludarium.IntegrationTests; public sealed class NativeBrowserPlayServiceTests { [Fact] public async Task ExactReadOnlyAllowlistedMappingStartsNativeSessionAndCancelsIt() { var gameId = Guid.NewGuid(); var store = new MemoryPlayStore(Candidate(gameId)); var service = Service(store); var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None); var launch = await service.StartAsync(gameId, "tester", CancellationToken.None); Assert.True(capability.Available); Assert.Equal("Ludarium EmulatorJS", capability.Emulator); Assert.StartsWith("/player.html?session=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.Contains("#token=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.DoesNotContain("firmware=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.DoesNotContain(launch.Token, store.Sessions[launch.Session.Id].LaunchUrl, StringComparison.Ordinal); Assert.Equal(64, launch.Token.Length); Assert.Null(await service.AuthorizeAsync(launch.Session.Id, "wrong-token", CancellationToken.None)); Assert.Equal(launch.Session.Id, (await service.AuthorizeAsync(launch.Session.Id, launch.Token, CancellationToken.None))?.Id); await service.CancelAsync(launch.Session.Id, "tester", CancellationToken.None); Assert.Equal(PlaySessionState.Cancelled, store.Sessions[launch.Session.Id].State); } [Fact] public async Task SelectedPersonalPsxBiosIsAdvertisedWithoutExposingItsIdentity() { var gameId = Guid.NewGuid(); var store = new MemoryPlayStore(Candidate(gameId, "psx", "roms/psx/synthetic.chd")); var firmware = new MemoryFirmwareStore(new FirmwareAsset(Guid.NewGuid(), "psx", "bios", FirmwareAssetKind.Bios, "personal.bin", 512, new string('f', 64), "private/location", true, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); var service = new NativeBrowserPlayService(store, null!, null!, true, firmwareStore: firmware); var launch = await service.StartAsync(gameId, "tester", CancellationToken.None); Assert.Contains("&firmware=1#token=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.DoesNotContain("personal.bin", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.DoesNotContain("private/location", launch.Session.LaunchUrl, StringComparison.Ordinal); } [Fact] public async Task Ps2UsesTheNativePlayJsPageWithoutDeliveringTheStoredBios() { var gameId = Guid.NewGuid(); var store = new MemoryPlayStore(Candidate(gameId, "ps2", "roms/ps2/synthetic.iso")); var firmware = new MemoryFirmwareStore(new FirmwareAsset(Guid.NewGuid(), "ps2", "bios", FirmwareAssetKind.Bios, "personal.bin", 512, new string('f', 64), "private/location", true, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); var service = new NativeBrowserPlayService(store, null!, null!, true, firmwareStore: firmware); var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None); var launch = await service.StartAsync(gameId, "tester", CancellationToken.None); Assert.True(capability.Available); Assert.Equal("Ludarium Play!.js", capability.Emulator); Assert.Equal("playjs", capability.Core); Assert.False(capability.AutomaticRestore); Assert.StartsWith("/ps2-player.html?session=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.DoesNotContain("firmware=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.DoesNotContain("personal.bin", launch.Session.LaunchUrl, StringComparison.Ordinal); } [Fact] public async Task N64UsesItsDedicatedWasmPlayerAndVerifiedRestoreContract() { var gameId = Guid.NewGuid(); var store = new MemoryPlayStore(Candidate(gameId, "n64", "roms/n64/synthetic.v64")); var service = new NativeBrowserPlayService(store, null!, null!, true); var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None); var launch = await service.StartAsync(gameId, "tester", CancellationToken.None); Assert.True(capability.Available); Assert.Equal("Ludarium N64Wasm", capability.Emulator); Assert.Equal("n64wasm", capability.Core); Assert.True(capability.AutomaticRestore); Assert.StartsWith("/n64-player.html?session=", launch.Session.LaunchUrl, StringComparison.Ordinal); Assert.Contains("&core=n64wasm", launch.Session.LaunchUrl, StringComparison.Ordinal); } [Fact] public async Task MissingGameAndMissingBundledRuntimeFailClosed() { var missing = await Service(new MemoryPlayStore(null)).GetCapabilityAsync(Guid.NewGuid(), CancellationToken.None); var gameId = Guid.NewGuid(); var unavailable = await new NativeBrowserPlayService(new MemoryPlayStore(Candidate(gameId)), null!, null!, false) .GetCapabilityAsync(gameId, CancellationToken.None); Assert.Equal(BrowserPlayState.MissingRom, missing.State); Assert.Equal(BrowserPlayState.EmulatorUnavailable, unavailable.State); } [Fact] public async Task MissingScanDerivedN64ZipFailsClosedWithoutBreakingCapabilityChecks() { var rootPath = Path.Combine(Path.GetTempPath(), "ludarium-browser-play-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(rootPath); try { var gameId = Guid.NewGuid(); var candidate = Candidate(gameId, "n64", "roms/n64/missing.zip"); var library = new LibraryRoot(Guid.NewGuid(), "Read-only games", rootPath, LibraryKind.Rom, true, HashPolicy.Sha256, false, 1, true, true, true, true); var artifact = new Artifact(candidate.ArtifactId, library.Id, candidate.RelativePath, candidate.Size, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.High, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, "n64"); var catalog = DispatchProxy.Create(); ((ArtifactDetailsStoreProxy)(object)catalog).Details = new(artifact, library, [], [], [], [], []); var service = new NativeBrowserPlayService(new MemoryPlayStore(candidate), catalog, new ReadOnlyLibraryFileSystem(), true); var capability = await service.GetCapabilityAsync(gameId, CancellationToken.None); var startError = await Assert.ThrowsAsync(() => service.StartAsync(gameId, "tester", CancellationToken.None)); Assert.False(capability.Available); Assert.Equal(BrowserPlayState.MissingRom, capability.State); Assert.Contains("no longer present or readable", capability.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("no longer present or readable", startError.Message, StringComparison.OrdinalIgnoreCase); } finally { Directory.Delete(rootPath, true); } } [Fact] public async Task ConcurrentSessionLimitFailsClosed() { var gameId = Guid.NewGuid(); var store = new MemoryPlayStore(Candidate(gameId)); var now = DateTimeOffset.UtcNow; for (var index = 0; index < 2; index++) { var session = new BrowserPlaySession(Guid.NewGuid(), gameId, Guid.NewGuid(), PlaySessionState.Active, "nes", "Ludarium EmulatorJS", "fceumm", "/player.html", "redacted", now, now.AddMinutes(30)); store.Sessions[session.Id] = session; } var error = await Assert.ThrowsAsync(() => Service(store).StartAsync(gameId, "tester", CancellationToken.None)); Assert.Contains("capacity", error.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(2, store.Sessions.Count); } private static NativeBrowserPlayService Service(MemoryPlayStore store) => new(store, null!, null!, true); private static BrowserPlayCandidate Candidate(Guid gameId, string platform = "nes", string relativePath = "roms/nes/synthetic.nes") => new(gameId, Guid.NewGuid(), "Synthetic", platform, relativePath, 16, null, true); private sealed class MemoryFirmwareStore(FirmwareAsset? selected) : IFirmwareStore { public Task> ListFirmwareAssetsAsync(CancellationToken cancellationToken) => Task.FromResult>(selected is null ? [] : [selected]); public Task GetFirmwareAssetAsync(Guid id, CancellationToken cancellationToken) => Task.FromResult(selected?.Id == id ? selected : null); public Task GetSelectedFirmwareAssetAsync(string platform, string slot, CancellationToken cancellationToken) => Task.FromResult(selected is not null && selected.Selected && selected.Platform.Equals(platform, StringComparison.OrdinalIgnoreCase) && selected.Slot.Equals(slot, StringComparison.OrdinalIgnoreCase) ? selected : null); public Task SaveFirmwareAssetAsync(FirmwareAsset asset, string actor, CancellationToken cancellationToken) => Task.CompletedTask; public Task> DeleteFirmwareAssetAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken) => Task.FromResult>([]); } public class ArtifactDetailsStoreProxy : DispatchProxy { public ArtifactDetails? Details { get; set; } protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => targetMethod?.Name switch { nameof(ILudariumStore.GetArtifactDetailsAsync) => Task.FromResult(Details), _ => throw new NotSupportedException(targetMethod?.Name) }; } private sealed class MemoryPlayStore(BrowserPlayCandidate? candidate) : IBrowserPlayStore { public Dictionary Sessions { get; } = []; public Task GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken cancellationToken) => Task.FromResult(candidate?.GameId == gameId ? candidate : null); public Task CountActiveBrowserPlaySessionsAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(Sessions.Values.Count(session => session.ExpiresAt > now && session.State is (PlaySessionState.Starting or PlaySessionState.Ready or PlaySessionState.Active))); public Task SaveBrowserPlaySessionAsync(BrowserPlaySession session, string actor, CancellationToken cancellationToken) { Sessions[session.Id] = session; return Task.CompletedTask; } public Task GetBrowserPlaySessionAsync(Guid sessionId, CancellationToken cancellationToken) => Task.FromResult(Sessions.GetValueOrDefault(sessionId)); public Task EndBrowserPlaySessionAsync(Guid sessionId, PlaySessionState state, string actor, string? errorCode, string? errorMessage, CancellationToken cancellationToken) { if (Sessions.TryGetValue(sessionId, out var session)) Sessions[sessionId] = session with { State = state, ErrorCode = errorCode, ErrorMessage = errorMessage, EndedAt = DateTimeOffset.UtcNow }; return Task.CompletedTask; } } }