This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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<ILudariumStore, ArtifactDetailsStoreProxy>();
|
||||
((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<ResourceConflictException>(() =>
|
||||
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<ResourceConflictException>(() =>
|
||||
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<IReadOnlyList<FirmwareAsset>> ListFirmwareAssetsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<FirmwareAsset>>(selected is null ? [] : [selected]);
|
||||
public Task<FirmwareAsset?> GetFirmwareAssetAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(selected?.Id == id ? selected : null);
|
||||
public Task<FirmwareAsset?> 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<IReadOnlyList<FirmwareAsset>> DeleteFirmwareAssetAsync(Guid id, long expectedVersion,
|
||||
string actor, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<FirmwareAsset>>([]);
|
||||
}
|
||||
|
||||
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<Guid, BrowserPlaySession> Sessions { get; } = [];
|
||||
public Task<BrowserPlayCandidate?> GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(candidate?.GameId == gameId ? candidate : null);
|
||||
public Task<int> 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<BrowserPlaySession?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user