Publish Ludarium source
Public source validation / source (push) Successful in 2m16s

This commit is contained in:
Ludarium release export
2026-09-03 02:08:58 +02:00
commit df869819ce
339 changed files with 46455 additions and 0 deletions
@@ -0,0 +1,381 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.ArchitectureTests;
public sealed class DependencyTests
{
[Fact]
public void DomainHasNoInfrastructureDependencies()
{
var references = typeof(LibraryRoot).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray();
Assert.DoesNotContain(references, x => x is not null && (x.StartsWith("Npgsql", StringComparison.Ordinal) || x.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal)));
}
[Fact]
public void ApplicationDoesNotReferenceInfrastructure()
{
var references = typeof(IScanCoordinator).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray();
Assert.DoesNotContain("Ludarium.Infrastructure", references);
}
[Fact]
public void ContainerSupervisorAndHealthProbeFailClosedOnDatabaseLoss()
{
var root = FindRepositoryRoot();
var entrypoint = File.ReadAllText(Path.Combine(root, "deploy", "ludarium-entrypoint.sh"));
var healthProbe = File.ReadAllText(Path.Combine(root, "src", "Ludarium.HealthCheck", "Program.cs"));
Assert.Contains("wait -n \"$postgres_pid\" \"$app_pid\"", entrypoint, StringComparison.Ordinal);
Assert.Contains("PostgreSQL exited unexpectedly", entrypoint, StringComparison.Ordinal);
Assert.Contains("terminate_children", entrypoint, StringComparison.Ordinal);
Assert.Contains("/health/ready", healthProbe, StringComparison.Ordinal);
Assert.DoesNotContain("/health/live", healthProbe, StringComparison.Ordinal);
}
[Fact]
public void UnraidImageUsesPatchedUnifiedNobleRuntimeBaseline()
{
var root = FindRepositoryRoot();
var dockerfile = File.ReadAllText(Path.Combine(root, "Dockerfile.unraid"));
var entrypoint = File.ReadAllText(Path.Combine(root, "deploy", "ludarium-entrypoint.sh"));
var collationRepair = File.ReadAllText(Path.Combine(root, "deploy", "repair-postgres-collation.sh"));
Assert.Contains("FROM postgres:16.15-bookworm@sha256:60f4761b9035e0b8d5218f701a8c3382f641bf12b1604822574cf5be3baeb537 AS postgres-entrypoint", dockerfile, StringComparison.Ordinal);
Assert.Contains("FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble@sha256:a4556ed033fa96f984bb7a8d348851cb2d36b1281dd2420070045f664fbb5f94 AS final", dockerfile, StringComparison.Ordinal);
Assert.Contains("ARG DEBIAN_FRONTEND=noninteractive", dockerfile, StringComparison.Ordinal);
Assert.Contains("LANG=C.UTF-8", dockerfile, StringComparison.Ordinal);
Assert.Contains("LC_ALL=C.UTF-8", dockerfile, StringComparison.Ordinal);
Assert.Contains("POSTGRES_PACKAGE_VERSION=16.15-0ubuntu0.24.04.1", dockerfile, StringComparison.Ordinal);
Assert.Contains("GOSU_PACKAGE_VERSION=1.17-1ubuntu0.24.04.3", dockerfile, StringComparison.Ordinal);
Assert.Contains("LOCALES_PACKAGE_VERSION=2.39-0ubuntu8.8", dockerfile, StringComparison.Ordinal);
Assert.Contains("locale-gen en_US.UTF-8", dockerfile, StringComparison.Ordinal);
Assert.Contains("locale -a | grep -Fx 'en_US.utf8'", dockerfile, StringComparison.Ordinal);
Assert.Contains("COPY --from=postgres-entrypoint /usr/local/bin/docker-entrypoint.sh", dockerfile, StringComparison.Ordinal);
Assert.Contains("COPY --chmod=755 deploy/repair-postgres-collation.sh", dockerfile, StringComparison.Ordinal);
Assert.Contains("mkdir -p \"$PGDATA\" /docker-entrypoint-initdb.d", dockerfile, StringComparison.Ordinal);
Assert.DoesNotContain("postgres:16.14-alpine", dockerfile, StringComparison.Ordinal);
Assert.DoesNotContain("FROM postgres:16.15-bookworm AS final", dockerfile, StringComparison.Ordinal);
Assert.DoesNotContain("apk add", dockerfile[LastStageStart(dockerfile)..], StringComparison.Ordinal);
Assert.Contains("gosu \"${PUID:-1654}:${PGID:-1654}\"", entrypoint, StringComparison.Ordinal);
Assert.Contains("/usr/local/bin/repair-postgres-collation.sh", entrypoint, StringComparison.Ordinal);
Assert.DoesNotContain("su-exec", entrypoint, StringComparison.Ordinal);
Assert.Contains("REINDEX DATABASE", collationRepair, StringComparison.Ordinal);
Assert.Contains("REFRESH COLLATION VERSION", collationRepair, StringComparison.Ordinal);
Assert.Contains("pg_database_collation_actual_version(oid)", collationRepair, StringComparison.Ordinal);
Assert.Contains("datcollversion is null", collationRepair, StringComparison.Ordinal);
Assert.Contains("semantic_variants", collationRepair, StringComparison.Ordinal);
Assert.Contains("contains_custom", collationRepair, StringComparison.Ordinal);
Assert.Contains("refusing automatic data changes", collationRepair, StringComparison.Ordinal);
Assert.Contains("^[A-Za-z_][A-Za-z0-9_]*$", collationRepair, StringComparison.Ordinal);
}
[Fact]
public void ProductionImagesSharePinnedPlayJsProvenance()
{
var root = FindRepositoryRoot();
var dockerfile = PlayJsStage(File.ReadAllText(Path.Combine(root, "Dockerfile")));
var unraidDockerfile = PlayJsStage(File.ReadAllText(Path.Combine(root, "Dockerfile.unraid")));
Assert.Equal(dockerfile, unraidDockerfile);
Assert.Contains("PLAYJS_UPSTREAM_COMMIT=04bde0df87ee7c0e2f0151b51bb2cc22c88541da", dockerfile, StringComparison.Ordinal);
Assert.Contains("PLAYJS_WASM_SHA256=de3ae0efdd7938d1b5ac754a096ec180094d64702ae84b962c4bb9f79abb1da7", dockerfile, StringComparison.Ordinal);
Assert.Contains("raw.githubusercontent.com/jpd002/Play-/${PLAYJS_UPSTREAM_COMMIT}/License.txt", dockerfile, StringComparison.Ordinal);
Assert.Contains("/playjs/PROVENANCE.txt", dockerfile, StringComparison.Ordinal);
}
[Fact]
public void ProductionImagesShareOfflineEmulatorJsPatch()
{
var root = FindRepositoryRoot();
var standardImage = File.ReadAllText(Path.Combine(root, "Dockerfile"));
var unraidImage = File.ReadAllText(Path.Combine(root, "Dockerfile.unraid"));
var dockerfile = EmulatorJsStage(standardImage);
var unraidDockerfile = EmulatorJsStage(unraidImage);
var managedGate = File.ReadAllText(Path.Combine(root, "deploy", "run-managed-validation.sh"));
var mainCompose = File.ReadAllText(Path.Combine(root, "deploy", "compose.yml"));
var managedWorkflowPath = Path.Combine(root, ".gitea", "workflows", "managed-validation.yml");
var deployWorkflowPath = Path.Combine(root, ".gitea", "workflows", "unraid-deploy.yml");
var publicWorkflowPath = Path.Combine(root, ".gitea", "workflows", "public-validation.yml");
var managedWorkflow = File.Exists(managedWorkflowPath) ? File.ReadAllText(managedWorkflowPath) : "";
var deployWorkflow = File.Exists(deployWorkflowPath) ? File.ReadAllText(deployWorkflowPath) : "";
var publicWorkflow = File.Exists(publicWorkflowPath) ? File.ReadAllText(publicWorkflowPath) : "";
Assert.Equal(dockerfile, unraidDockerfile);
Assert.Contains("EMULATORJS_VERSION=4.2.3", dockerfile, StringComparison.Ordinal);
Assert.Contains("COPY .build-inputs/emulatorjs/ /tmp/emulatorjs-input/", dockerfile, StringComparison.Ordinal);
Assert.Contains("s#https://cdn.emulatorjs.org/stable/data/version.json#/emulatorjs/data/version.json#g", dockerfile, StringComparison.Ordinal);
Assert.Contains("/emulatorjs/LUDARIUM_PATCHES.txt", dockerfile, StringComparison.Ordinal);
Assert.Contains("--dns 1.1.1.1 --dns 8.8.8.8", managedGate, StringComparison.Ordinal);
Assert.Contains("07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b", managedGate, StringComparison.Ordinal);
Assert.Contains("cleanup_build_inputs", managedGate, StringComparison.Ordinal);
foreach (var workflow in new[] { managedWorkflow, deployWorkflow, publicWorkflow }.Where(x => x.Length > 0))
{
Assert.Contains("actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9", workflow, StringComparison.Ordinal);
Assert.Contains("actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", workflow, StringComparison.Ordinal);
}
if (managedWorkflow.Length > 0)
{
Assert.Contains("run-managed-validation.sh source", managedWorkflow, StringComparison.Ordinal);
var manualJobStart = managedWorkflow.IndexOf("\n manual:\n", StringComparison.Ordinal);
Assert.True(manualJobStart >= 0);
Assert.DoesNotContain("DOCKER_HOST:", managedWorkflow[..manualJobStart], StringComparison.Ordinal);
}
if (deployWorkflow.Length > 0)
{
Assert.Contains("DOCKER_HOST: tcp://172.17.0.1:2375", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("LUDARIUM_DOCKER_BUILD_NETWORK: host", deployWorkflow, StringComparison.Ordinal);
}
if (publicWorkflow.Length > 0)
{
Assert.Contains("run-managed-validation.sh source", publicWorkflow, StringComparison.Ordinal);
Assert.DoesNotContain("DOCKER_HOST:", publicWorkflow, StringComparison.Ordinal);
}
foreach (var image in new[] { standardImage, unraidImage })
{
Assert.Contains("org.opencontainers.image.revision", image, StringComparison.Ordinal);
Assert.Contains("org.opencontainers.image.version", image, StringComparison.Ordinal);
}
Assert.Contains("LUDARIUM_SOURCE_REVISION: ${GITEA_COMMIT_SHA:-local}", mainCompose, StringComparison.Ordinal);
if (deployWorkflow.Length > 0)
{
var deployJobStart = deployWorkflow.IndexOf("\n deploy:\n", StringComparison.Ordinal);
Assert.True(deployJobStart >= 0);
var deployJob = deployWorkflow[deployJobStart..];
Assert.DoesNotContain("uses:", deployJob, StringComparison.Ordinal);
Assert.DoesNotContain("< VERSION", deployJob, StringComparison.Ordinal);
var runnerContract = deployJob.IndexOf("Validate deploy runner contract and exact revision", StringComparison.Ordinal);
var deployBackup = deployJob.IndexOf("Create pre-promotion database backup", StringComparison.Ordinal);
var deployPromote = deployJob.IndexOf("Deploy exact Gitea revision", StringComparison.Ordinal);
Assert.True(runnerContract >= 0 && runnerContract < deployBackup && deployBackup < deployPromote);
Assert.Contains("^[0-9a-f]{40}$", deployJob, StringComparison.Ordinal);
Assert.Contains("promotion refused: GITHUB_SHA is not a 40-character", deployJob, StringComparison.Ordinal);
Assert.Contains("promotion refused: unexpected repository", deployJob, StringComparison.Ordinal);
Assert.Contains("command -v \"$tool\"", deployJob, StringComparison.Ordinal);
Assert.Contains("promotion refused: the gitea-deploy-control controller is not running", deployJob, StringComparison.Ordinal);
Assert.Contains("Create pre-promotion database backup", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("docker exec Ludarium pg_dump -U ludarium -Fc ludarium", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("docker exec -i Ludarium pg_restore --list", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("/mnt/user/appdata/ludarium/backups/pre-${GITHUB_SHA}", deployWorkflow, StringComparison.Ordinal);
Assert.DoesNotContain("source/deploy/data/backups", deployJob, StringComparison.Ordinal);
Assert.Contains("partial_path=\"${backup_path}.partial\"", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("promotion refused: the production database is not running", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("Verify exact live image identity and source policy", deployWorkflow, StringComparison.Ordinal);
var revisionGate = deployJob.IndexOf("prepared_revision\" != \"$GITHUB_SHA", StringComparison.Ordinal);
var versionRead = deployJob.IndexOf("/mnt/user/appdata/ludarium/source/VERSION", StringComparison.Ordinal);
Assert.True(revisionGate >= 0 && versionRead > revisionGate);
Assert.Contains("promotion refused: the synced VERSION is not a release identifier", deployJob, StringComparison.Ordinal);
Assert.Contains("promotion refused: the pre-promotion backup did not survive", deployJob, StringComparison.Ordinal);
Assert.Contains("test \"$actual_image\" = \"$expected_image\"", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("test \"$actual_revision\" = \"$GITHUB_SHA\"", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("test \"$actual_version\" = \"$release\"", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("restart-baseline", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("if [ \"$actual_id\" = \"$baseline_id\" ]", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("if [ \"$restart_count\" != \"$expected_restarts\" ]", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("test \"$readonly_root\" = \"true\"", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("test \"$no_new_privileges\" = '[\"no-new-privileges:true\"]'", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("test \"$cap_drop\" = '[\"ALL\"]'", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("test \"$cap_add\" = '[\"CAP_CHOWN\",\"CAP_DAC_OVERRIDE\",\"CAP_FOWNER\",\"CAP_KILL\",\"CAP_SETGID\",\"CAP_SETUID\"]'", deployWorkflow, StringComparison.Ordinal);
Assert.Contains("for dest in /library/games /library/ps4 /library/ps5", deployWorkflow, StringComparison.Ordinal);
}
}
[Fact]
public void SwitchControllerDiscoversTheActivePixelFluxDisplay()
{
var root = FindRepositoryRoot();
var shared = File.ReadAllText(Path.Combine(root, "deploy", "ludarium_sidecar.py"));
var compose = File.ReadAllText(Path.Combine(root, "deploy", "compose.switch.yml"));
// The active display is read from the running emulator process; the socket scan is only a
// bounded fallback. This lived in the Eden controller alone, which is exactly how the
// Dolphin sidecar shipped without it.
Assert.Contains("def desktop_display(process_name):", shared, StringComparison.Ordinal);
Assert.Contains("/proc/{pid}/environ", shared, StringComparison.Ordinal);
Assert.Contains("Path(\"/tmp/.X11-unix\").glob(\"X*\")", shared, StringComparison.Ordinal);
Assert.Contains("\"DISPLAY\": desktop_display(process_name)", shared, StringComparison.Ordinal);
Assert.DoesNotContain("\"DISPLAY\": \":0\"", shared, StringComparison.Ordinal);
// Every sidecar must obtain its runtime environment from the shared module, never its own copy.
foreach (var controller in new[] { "eden-controller.py", "dolphin-controller.py" })
{
var text = File.ReadAllText(Path.Combine(root, "deploy", controller));
Assert.Contains("import ludarium_sidecar as sidecar", text, StringComparison.Ordinal);
Assert.Contains("sidecar.EmulatorRuntime(", text, StringComparison.Ordinal);
Assert.DoesNotContain("def desktop_display", text, StringComparison.Ordinal);
Assert.DoesNotContain("subprocess.Popen", text, StringComparison.Ordinal);
}
foreach (var dockerfile in new[] { "Dockerfile.eden-controller", "Dockerfile.dolphin-controller" })
Assert.Contains("deploy/ludarium_sidecar.py /opt/ludarium/ludarium_sidecar.py",
File.ReadAllText(Path.Combine(root, dockerfile)), StringComparison.Ordinal);
Assert.Contains("lscr.io/linuxserver/eden@sha256:f18ec24d55266daab9a440aba5fe3e9587494cf7a9aad63f93806efe4081be83",
File.ReadAllText(Path.Combine(root, "Dockerfile.eden-controller")), StringComparison.Ordinal);
Assert.Contains("ludarium/eden-controller:0.4.9-rc.1", compose, StringComparison.Ordinal);
}
[Fact]
public void SwitchSidecarRemovesUnusedPrivilegedToolingAndRequiresHealth()
{
var root = FindRepositoryRoot();
var dockerfile = File.ReadAllText(Path.Combine(root, "Dockerfile.eden-controller"));
var compose = File.ReadAllText(Path.Combine(root, "deploy", "compose.switch.yml"));
foreach (var package in new[]
{
"containerd.io", "docker-ce", "docker-ce-cli", "docker-compose-plugin",
"gcc", "g++", "make", "cmake", "git", "openssh-client", "cron", "curl", "perl"
})
Assert.Contains(package, dockerfile, StringComparison.Ordinal);
Assert.Contains("apt-get purge -y --auto-remove", dockerfile, StringComparison.Ordinal);
Assert.Contains("contents.d/svc-cron", dockerfile, StringComparison.Ordinal);
Assert.Contains("contents.d/svc-docker", dockerfile, StringComparison.Ordinal);
Assert.Contains("condition: service_healthy", compose, StringComparison.Ordinal);
Assert.Contains("http://127.0.0.1:8765/health", compose, StringComparison.Ordinal);
Assert.Contains("no-new-privileges:true", compose, StringComparison.Ordinal);
Assert.DoesNotContain("/var/run/docker.sock", compose, StringComparison.Ordinal);
var audit = File.ReadAllText(Path.Combine(root, "deploy", "audit-eden-runtime.sh"));
Assert.Contains("/games is not mounted read-only", audit, StringComparison.Ordinal);
Assert.Contains("nginx map directives are forbidden", audit, StringComparison.Ordinal);
Assert.Contains("Xwayland TCP listening is enabled", audit, StringComparison.Ordinal);
Assert.Contains("a runtime .netrc file is present", audit, StringComparison.Ordinal);
Assert.Contains("a RIST media URL is present", audit, StringComparison.Ordinal);
Assert.Contains("deploy/audit-eden-runtime.sh", dockerfile, StringComparison.Ordinal);
Assert.Contains("eden-controller-0.4.9.openvex.json", dockerfile, StringComparison.Ordinal);
var securityGate = File.ReadAllText(Path.Combine(root, "deploy", "run-eden-security-gate.sh"));
Assert.Contains("grype-0.116.1", securityGate, StringComparison.Ordinal);
Assert.Contains("--fail-on critical", securityGate, StringComparison.Ordinal);
Assert.Contains("VEX does not account for every raw Critical match", securityGate, StringComparison.Ordinal);
Assert.Contains("cmp -s \"$work/raw-ids\" \"$work/vex-ids\"", securityGate, StringComparison.Ordinal);
Assert.Contains("/games false", securityGate, StringComparison.Ordinal);
Assert.Contains("audit-eden-runtime.sh", securityGate, StringComparison.Ordinal);
var attributes = File.ReadAllText(Path.Combine(root, ".gitattributes"));
Assert.Contains("*.sh text eol=lf", attributes, StringComparison.Ordinal);
Assert.Contains("Dockerfile.* text eol=lf", attributes, StringComparison.Ordinal);
}
[Fact]
public void DolphinSidecarIsPinnedExactTitleControlledAndReadOnly()
{
var root = FindRepositoryRoot();
var dockerfile = File.ReadAllText(Path.Combine(root, "Dockerfile.dolphin-controller"));
var compose = File.ReadAllText(Path.Combine(root, "deploy", "compose.dolphin.yml"));
var controller = File.ReadAllText(Path.Combine(root, "deploy", "dolphin-controller.py"));
Assert.Contains("dolphin:2606-ls78@sha256:7b916f1c97d591b96525b67128c1bf82ce6ede0eff6816fe1c0a3d70f744506d", dockerfile, StringComparison.Ordinal);
Assert.Contains(":/games/gamecube:ro", compose, StringComparison.Ordinal);
Assert.Contains(":/games/wii:ro", compose, StringComparison.Ordinal);
Assert.Contains("no-new-privileges:true", compose, StringComparison.Ordinal);
Assert.DoesNotContain("/var/run/docker.sock", compose, StringComparison.Ordinal);
Assert.Contains("SELKIES_COMMAND_ENABLED: \"false|locked\"", compose, StringComparison.Ordinal);
Assert.Contains("LUDARIUM_DOLPHIN_FIXTURE_SHA256", compose, StringComparison.Ordinal);
Assert.Contains("extensions={\".iso\", \".gcm\", \".rvz\", \".gcz\", \".wbfs\", \".wia\"}", controller, StringComparison.Ordinal);
Assert.Contains("platforms={\"gamecube\", \"wii\"}", controller, StringComparison.Ordinal);
Assert.Contains("/usr/bin/dolphin-emu", controller, StringComparison.Ordinal);
Assert.Contains("save-state", controller, StringComparison.Ordinal);
Assert.Contains("load-state", controller, StringComparison.Ordinal);
}
[Fact]
public void UnraidContainersExposeStableDockerManMetadata()
{
var root = FindRepositoryRoot();
var mainCompose = File.ReadAllText(Path.Combine(root, "deploy", "compose.yml"));
var switchCompose = File.ReadAllText(Path.Combine(root, "deploy", "compose.switch.yml"));
var dolphinCompose = File.ReadAllText(Path.Combine(root, "deploy", "compose.dolphin.yml"));
var template = File.ReadAllText(Path.Combine(root, "deploy", "unraid", "my-Ludarium.xml"));
Assert.Contains("net.unraid.docker.webui: \"${LUDARIUM_PUBLIC_URL:-http://[IP]:[PORT:1230]/}\"", mainCompose, StringComparison.Ordinal);
Assert.Contains("net.unraid.docker.webui: \"${SWITCH_PUBLIC_URL:-http://[IP]:[PORT:1262]/}\"", switchCompose, StringComparison.Ordinal);
Assert.Contains("net.unraid.docker.webui: \"${DOLPHIN_PUBLIC_URL:-http://[IP]:[PORT:1263]/}\"", dolphinCompose, StringComparison.Ordinal);
Assert.Contains("net.unraid.docker.managed: \"dockerman\"", mainCompose, StringComparison.Ordinal);
Assert.Contains("net.unraid.docker.managed: \"dockerman\"", switchCompose, StringComparison.Ordinal);
Assert.Contains("net.unraid.docker.managed: \"dockerman\"", dolphinCompose, StringComparison.Ordinal);
foreach (var document in new[] { mainCompose, switchCompose, dolphinCompose, template })
{
Assert.Contains("/boot/config/plugins/dockerMan/images/Ludarium-icon.svg", document, StringComparison.Ordinal);
Assert.DoesNotContain("Ludarium-icon.png", document, StringComparison.Ordinal);
}
}
[Fact]
public void ReleaseEvidenceReadsTheSingleDeclaredSchemaVersion()
{
var root = FindRepositoryRoot();
var gate = File.ReadAllText(Path.Combine(root, "deploy", "run-candidate-gate.sh"));
var support = File.ReadAllText(Path.Combine(root, "src", "Ludarium.Infrastructure", "SupportBundleService.cs"));
Assert.Contains("CurrentSchemaVersion", gate, StringComparison.Ordinal);
Assert.Contains("candidate-schema${expected_schema}.dump", gate, StringComparison.Ordinal);
Assert.Contains("restored_schema\" = \"$expected_schema", gate, StringComparison.Ordinal);
Assert.DoesNotContain("candidate-schema23", gate, StringComparison.Ordinal);
Assert.Contains("PostgresStore.CurrentSchemaVersion", support, StringComparison.Ordinal);
Assert.DoesNotContain("schemaVersion = 23", support, StringComparison.Ordinal);
}
private static string PlayJsStage(string dockerfile)
{
const string startMarker = " AS playjs";
const string endMarker = " AS n64wasm";
var start = dockerfile.IndexOf(startMarker, StringComparison.Ordinal);
var end = dockerfile.IndexOf(endMarker, start, StringComparison.Ordinal);
Assert.True(start >= 0 && end > start, "The Play!.js image stage must be present.");
return dockerfile[start..end].ReplaceLineEndings("\n");
}
private static string EmulatorJsStage(string dockerfile)
{
const string startMarker = " AS emulator";
const string endMarker = " AS playjs";
var start = dockerfile.IndexOf(startMarker, StringComparison.Ordinal);
var end = dockerfile.IndexOf(endMarker, start, StringComparison.Ordinal);
Assert.True(start >= 0 && end > start, "The EmulatorJS image stage must be present.");
return dockerfile[start..end].ReplaceLineEndings("\n");
}
private static int LastStageStart(string dockerfile)
{
var start = dockerfile.LastIndexOf(" AS final", StringComparison.Ordinal);
Assert.True(start >= 0, "The patched unified final stage must be present.");
return start;
}
[Fact]
public void EveryMappedBrowserCoreStaysInTheImage()
{
var dockerfile = File.ReadAllText(Path.Combine(FindRepositoryRoot(), "Dockerfile"));
var retention = dockerfile.Split('\n')
.Single(line => line.Contains("/emulatorjs/data/cores", StringComparison.Ordinal) &&
line.Contains("-delete", StringComparison.Ordinal));
// The image build deletes every core it does not name. A platform mapped to a core that was
// not kept would fail at launch with no build-time signal.
foreach (var core in BrowserPlayPolicy.RequiredCores)
Assert.Contains($"! -name '{core}-*'", retention, StringComparison.Ordinal);
// N64 runs on Ludarium's own N64Wasm player, so no EmulatorJS N64 core can ever be selected.
Assert.DoesNotContain("mupen64plus_next", retention, StringComparison.Ordinal);
}
[Fact]
public void EveryNativePlayerPlatformIsOutsideTheBrowserMatrix()
{
// A platform served by an isolated sidecar must never also claim a browser core: the game
// detail page picks one player, and two owners would make that choice arbitrary.
foreach (var platform in NativeRemotePlayerRegistry.Platforms)
Assert.Null(BrowserPlayPolicy.GetPlatform(platform));
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Ludarium.slnx")))
directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("The Ludarium repository root was not found.");
}
}
@@ -0,0 +1,27 @@
<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.Domain\Ludarium.Domain.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Application\Ludarium.Application.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Infrastructure\Ludarium.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Api\Ludarium.Api.csproj" />
</ItemGroup>
</Project>
@@ -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, )"
}
}
}
}
}
@@ -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));
}
}
+24
View File
@@ -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);
}
}
+43
View File
@@ -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);
}
}
+179
View File
@@ -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, )"
}
}
}
}
}
@@ -0,0 +1,71 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// What a scan concludes about the files that were not there any more.
/// </summary>
/// <remarks>
/// Reconciliation compared the stored state against the name "Present" while every other query in
/// the store reads the enum's ordinal. The comparison matched no row, so a ROM deleted from the
/// archive stayed Present: it kept its Play control, counted towards the archive's size and never
/// appeared as missing. Only the real database can prove the ordinal contract, so it is proven here.
/// </remarks>
public sealed class ArtifactReconciliationTests
{
[Fact]
[Trait("Category", "Container")]
public async Task AFileThatLeftTheArchiveIsMarkedMissing()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_reconciliation_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Reconciliation fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-reconciliation-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var observed = "roms/nes/Kept.nes";
var removed = "roms/nes/Deleted.nes";
foreach (var path in new[] { observed, removed })
await store.UpsertArtifactAsync(
new(Guid.NewGuid(), library.Id, path, 24_592, DateTimeOffset.UtcNow.AddDays(-2),
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null,
DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddDays(-2), null, "nes"),
null, [], CancellationToken.None);
var at = DateTimeOffset.UtcNow;
var marked = await store.MarkMissingExceptAsync(library.Id, new HashSet<string>([observed]), at, CancellationToken.None);
Assert.Equal(1, marked);
var gone = await store.FindByPathAsync(library.Id, removed, CancellationToken.None);
// Reading it back through the store proves the written state is the shape the record expects.
Assert.Equal(ArtifactState.Missing, gone!.State);
Assert.Equal(2, gone.Version);
var kept = await store.FindByPathAsync(library.Id, observed, CancellationToken.None);
Assert.Equal(ArtifactState.Present, kept!.State);
Assert.Equal(1, kept.Version);
// The same ordinal contract is read by the storage summary, so the archive reports the loss.
var summary = await store.GetStorageSummaryAsync(CancellationToken.None);
var entry = Assert.Single(summary.Libraries, item => item.LibraryId == library.Id);
Assert.Equal(1, entry.Missing);
// A second scan that still cannot see the file leaves it alone rather than counting it twice.
Assert.Equal(0, await store.MarkMissingExceptAsync(library.Id, new HashSet<string>([observed]), at, CancellationToken.None));
}
}
@@ -0,0 +1,7 @@
namespace Ludarium.IntegrationTests;
public sealed class ContractTests
{
[Fact]
public void ApiAssemblyIsLoadable() => Assert.NotNull(typeof(Program).Assembly.EntryPoint);
}
@@ -0,0 +1,309 @@
using System.Globalization;
using System.IO.Compression;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
/// <summary>
/// CUE/BIN sets served to the browser player as one streamed archive.
/// </summary>
/// <remarks>
/// A CUE sheet is operator content read straight out of a read-only library, so the parser is the
/// boundary that decides which files Ludarium will open. An incomplete or escaping set must fail
/// closed rather than start a half-readable disc.
/// </remarks>
public sealed class DiscSetArchiveTests : IDisposable
{
private readonly string root = Directory.CreateTempSubdirectory("ludarium-cue-").FullName;
public void Dispose() => Directory.Delete(root, recursive: true);
[Fact]
public void ASingleTrackSheetResolvesToItsOneTrack()
{
var files = DiscSetArchive.ParseTrackFiles("""
FILE "Ludarium Fixture (Track 1).bin" BINARY
TRACK 01 MODE2/2352
INDEX 01 00:00:00
""");
Assert.Equal(["Ludarium Fixture (Track 1).bin"], files);
}
[Fact]
public void AMultiTrackSheetKeepsEveryDistinctTrackInOrder()
{
var files = DiscSetArchive.ParseTrackFiles("""
FILE "Disc (Track 1).bin" BINARY
TRACK 01 MODE2/2352
INDEX 01 00:00:00
FILE "Disc (Track 2).bin" BINARY
TRACK 02 AUDIO
INDEX 01 00:00:00
FILE "Disc (Track 2).bin" BINARY
TRACK 03 AUDIO
INDEX 01 02:00:00
""");
Assert.Equal(["Disc (Track 1).bin", "Disc (Track 2).bin"], files);
}
[Fact]
public void AnUnquotedTrackNameIsStillRead()
{
Assert.Equal(["game.bin"], DiscSetArchive.ParseTrackFiles("FILE game.bin BINARY\n TRACK 01 MODE1/2352"));
}
[Theory]
[InlineData("FILE \"../../etc/passwd\" BINARY")]
[InlineData("FILE \"sub/dir/track.bin\" BINARY")]
[InlineData("FILE \"C:\\\\windows\\\\system32\\\\a.bin\" BINARY")]
[InlineData("FILE \"payload.exe\" BINARY")]
[InlineData("FILE \"..\" BINARY")]
public void ASheetThatEscapesItsDirectoryOrNamesAnExecutableIsRejected(string line) =>
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles(line));
[Fact]
public void ASheetWithoutTracksIsRejected()
{
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles("REM GENRE Puzzle\nREM DATE 1996"));
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles("FILENAME whatever.bin BINARY"));
}
[Fact]
public void ASheetNamingMoreFilesThanOneDiscCanHoldIsRejected()
{
var lines = string.Join('\n', Enumerable.Range(0, DiscSetArchive.MaximumTrackFiles + 2)
.Select(index => $"FILE \"track{index:D2}.bin\" BINARY"));
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParseTrackFiles(lines));
}
[Fact]
public async Task ACompleteSetResolvesWithItsTotalSize()
{
WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Track 2).bin", 2048);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin");
var set = await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
cue, 2L * 1024 * 1024 * 1024, CancellationToken.None);
Assert.NotNull(set);
Assert.Equal(2, set.MemberPaths.Count - 1);
Assert.Equal(cue + 4096 + 2048, set.TotalBytes);
Assert.All(set.MemberPaths, path => Assert.StartsWith("roms/psx/Game/", path, StringComparison.Ordinal));
}
[Fact]
public async Task AMissingTrackFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
cue, 2L * 1024 * 1024 * 1024, CancellationToken.None));
}
[Fact]
public async Task ASetLargerThanThePlatformLimitFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Track 1).bin", 8192);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
cue, 4096, CancellationToken.None));
}
[Fact]
public async Task AnOversizedSheetIsNotEvenRead()
{
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.cue",
DiscSetArchive.MaximumDescriptorBytes + 1, long.MaxValue, CancellationToken.None));
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.iso",
cue, long.MaxValue, CancellationToken.None));
}
[Fact]
public async Task TheStreamedArchiveContainsTheSheetAndEveryTrackVerbatim()
{
var first = WriteTrack("roms/psx/Game/Game (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Track 2).bin", 2048);
var cue = WriteCue("roms/psx/Game/Game.cue", "Game (Track 1).bin", "Game (Track 2).bin");
var files = FileSystem();
var set = await DiscSetArchive.ResolveAsync(files, Library(), "roms/psx/Game/Game.cue", cue,
long.MaxValue, CancellationToken.None);
var opened = DiscSetArchive.Open(set!, files, Library(), CancellationToken.None);
Assert.Equal("Game.zip", opened.FileName);
// The reader is a live pipe, so the archive is buffered exactly as the player would receive it.
using var buffer = new MemoryStream();
await using (var content = opened.Content) await content.CopyToAsync(buffer);
buffer.Position = 0;
using var archive = new ZipArchive(buffer, ZipArchiveMode.Read);
Assert.Equal(["Game.cue", "Game (Track 1).bin", "Game (Track 2).bin"],
archive.Entries.Select(entry => entry.FullName));
Assert.Equal(4096, archive.Entries[1].Length);
Assert.Equal(2048, archive.Entries[2].Length);
await using var track = archive.Entries[1].Open();
var extracted = new byte[4096];
await track.ReadExactlyAsync(extracted);
Assert.Equal(first, extracted);
}
[Fact]
public void APlaylistKeepsEveryDistinctDiscInOrder()
{
var discs = DiscSetArchive.ParsePlaylist("""
# Ludarium multi-disc fixture
Game (Disc 1).cue
Game (Disc 2).cue
Game (Disc 2).cue
""");
Assert.Equal(["Game (Disc 1).cue", "Game (Disc 2).cue"], discs);
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("discs/Game (Disc 1).cue")]
[InlineData("Game (Disc 1).m3u")]
[InlineData("payload.exe")]
public void APlaylistThatEscapesItsDirectoryOrNestsIsRejected(string line) =>
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParsePlaylist(line));
[Fact]
public void APlaylistWithoutDiscsIsRejected() =>
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParsePlaylist("# only a comment\n\n"));
[Fact]
public void APlaylistNamingMoreDiscsThanOneGameCanHoldIsRejected()
{
var lines = string.Join('\n', Enumerable.Range(0, DiscSetArchive.MaximumDiscs + 2)
.Select(index => $"Disc {index}.chd"));
Assert.Throws<InvalidDataException>(() => DiscSetArchive.ParsePlaylist(lines));
}
[Fact]
public async Task AMultiDiscPlaylistResolvesEveryDiscAndItsTracks()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Disc 2) (Track 1).bin", 2048);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
var set = await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u",
playlist, long.MaxValue, CancellationToken.None);
Assert.NotNull(set);
Assert.Equal(2, set.DiscCount);
// The playlist, both sheets and both tracks travel together or the game does not start.
Assert.Equal(5, set.MemberPaths.Count);
Assert.Equal("roms/psx/Game/Game.m3u", set.MemberPaths[0]);
}
[Fact]
public async Task APlaylistMissingOneDiscFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u",
playlist, long.MaxValue, CancellationToken.None));
}
[Fact]
public async Task APlaylistMissingOneTrackOfOneDiscFailsClosed()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
Assert.Null(await DiscSetArchive.ResolveAsync(FileSystem(), Library(), "roms/psx/Game/Game.m3u",
playlist, long.MaxValue, CancellationToken.None));
}
[Fact]
public async Task AMultiDiscArchiveCarriesThePlaylistAndEveryMember()
{
WriteTrack("roms/psx/Game/Game (Disc 1) (Track 1).bin", 4096);
WriteTrack("roms/psx/Game/Game (Disc 2) (Track 1).bin", 2048);
WriteCue("roms/psx/Game/Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin");
WriteCue("roms/psx/Game/Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin");
var playlist = WritePlaylist("roms/psx/Game/Game.m3u", "Game (Disc 1).cue", "Game (Disc 2).cue");
var files = FileSystem();
var set = await DiscSetArchive.ResolveAsync(files, Library(), "roms/psx/Game/Game.m3u", playlist,
long.MaxValue, CancellationToken.None);
var opened = DiscSetArchive.Open(set!, files, Library(), CancellationToken.None);
using var buffer = new MemoryStream();
await using (var content = opened.Content) await content.CopyToAsync(buffer);
buffer.Position = 0;
using var archive = new ZipArchive(buffer, ZipArchiveMode.Read);
Assert.Equal("Game.zip", opened.FileName);
Assert.Equal(["Game.m3u", "Game (Disc 1).cue", "Game (Disc 1) (Track 1).bin",
"Game (Disc 2).cue", "Game (Disc 2) (Track 1).bin"],
archive.Entries.Select(entry => entry.FullName));
}
[Fact]
public void APlaylistIsRecognisedByExtensionAlone()
{
Assert.True(DiscSetArchive.IsPlaylist("roms/psx/Game/Game.M3U"));
Assert.True(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.m3u"));
Assert.True(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.cue"));
Assert.False(DiscSetArchive.IsDescriptor("roms/psx/Game/Game.chd"));
}
private long WritePlaylist(string relativePath, params string[] discs)
{
var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, string.Concat(discs.Select(disc => disc + "\n")));
return new FileInfo(path).Length;
}
[Fact]
public void ACueSheetIsRecognisedByExtensionAlone()
{
Assert.True(DiscSetArchive.IsCueSheet("roms/psx/Game/Game.CUE"));
Assert.False(DiscSetArchive.IsCueSheet("roms/psx/Game/Game.bin"));
Assert.False(DiscSetArchive.IsCueSheet(null));
}
private static ReadOnlyLibraryFileSystem FileSystem() => new();
private LibraryRoot Library() => new(Guid.NewGuid(), "fixture", root, LibraryKind.Rom, true,
HashPolicy.Sha256, false, 1, true, true, IsReadOnly: true);
private byte[] WriteTrack(string relativePath, int size)
{
var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var content = Enumerable.Range(0, size).Select(index => (byte)(index % 251)).ToArray();
File.WriteAllBytes(path, content);
return content;
}
private long WriteCue(string relativePath, params string[] tracks)
{
var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var lines = tracks.Select((track, index) => string.Create(CultureInfo.InvariantCulture,
$"FILE \"{track}\" BINARY\n TRACK {index + 1:D2} {(index == 0 ? "MODE2/2352" : "AUDIO")}\n INDEX 01 00:00:00\n"));
File.WriteAllText(path, string.Concat(lines));
return new FileInfo(path).Length;
}
}
@@ -0,0 +1,72 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// The title a scan derives from a file name.
/// </summary>
/// <remarks>
/// The extensions stripped from a derived title were listed separately in SQL from the extensions
/// the identifier recognises, and the two drifted apart: a Mega Drive ROM was identified correctly
/// and then presented as "Sonic Adventure md". Both now read one vocabulary, and a catalog that
/// already carries the old spelling keeps its game rather than gaining a second one.
/// </remarks>
public sealed class DiscoveredGameTitleTests
{
[Fact]
[Trait("Category", "Container")]
public async Task ACartridgeExtensionDoesNotSurviveIntoTheTitle()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_title_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Title fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-title-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
// The catalog already holds this game under the spelling the old query produced, discovered by
// a scan rather than named by hand: a manually named game keeps the operator's title.
await using (var legacy = dataSource.CreateCommand("""
INSERT INTO games(id,title,version,data,origin)
VALUES(md5('ludarium:game:sonic adventure.md')::uuid, 'Sonic Adventure md', 1,
jsonb_build_object('id', md5('ludarium:game:sonic adventure.md')::uuid,
'title', 'Sonic Adventure md', 'createdAt', now(), 'version', 1, 'origin', 0), 0)
"""))
await legacy.ExecuteNonQueryAsync(CancellationToken.None);
foreach (var (path, platform) in new[]
{
("roms/genesis/Sonic Adventure.md", "genesis"),
("roms/mastersystem/Alex Quest.sms", "mastersystem"),
("roms/gamegear/Pocket Racer.gg", "gamegear"),
})
await store.UpsertArtifactAsync(
new(Guid.NewGuid(), library.Id, path, 65_536, DateTimeOffset.UtcNow.AddDays(-1),
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1), null, platform),
null, [], CancellationToken.None);
await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None);
var catalog = await store.SearchGamesAsync(new GameQuery { PageSize = 50 }, CancellationToken.None);
var titles = catalog.Items.Select(game => game.Title).OrderBy(title => title, StringComparer.Ordinal).ToArray();
Assert.Equal(["Alex Quest", "Pocket Racer", "Sonic Adventure"], titles);
// The pre-existing row was recognised as the same game, so no duplicate was left behind.
Assert.Equal(3, catalog.Total);
}
}
@@ -0,0 +1,47 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class FirmwareVaultFileStoreTests : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), $"ludarium-firmware-{Guid.NewGuid():N}");
[Fact]
public async Task AppOwnedUploadIsHashedVerifiedAndDeletable()
{
var store = new FirmwareVaultFileStore(root);
var bytes = Enumerable.Range(0, 512).Select(value => (byte)(value % 251)).ToArray();
var asset = await store.SaveAsync(FirmwarePolicy.Get("psx", "bios"), Guid.NewGuid(), "personal.bin",
new MemoryStream(bytes), bytes.Length, CancellationToken.None);
Assert.Equal(bytes.Length, asset.Length);
Assert.Equal(64, asset.Sha256.Length);
await using (var opened = (await store.OpenAsync(asset, CancellationToken.None))!.Content)
{
using var copy = new MemoryStream();
await opened.CopyToAsync(copy);
Assert.Equal(bytes, copy.ToArray());
}
Assert.True(store.Delete(asset));
Assert.Null(await store.OpenAsync(asset, CancellationToken.None));
}
[Theory]
[InlineData("MZhost executable")]
[InlineData("#!/bin/sh")]
[InlineData("PK\u0003\u0004archive")]
public async Task HostExecutablesScriptsAndArchivesFailClosed(string text)
{
var store = new FirmwareVaultFileStore(root);
await Assert.ThrowsAsync<FirmwareValidationException>(() => store.SaveAsync(
FirmwarePolicy.Get("psx", "bios"), Guid.NewGuid(), "personal.bin",
new MemoryStream(Encoding.UTF8.GetBytes(text)), null, CancellationToken.None));
}
public void Dispose()
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
@@ -0,0 +1,45 @@
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class GameArtworkEnricherTests
{
[Theory]
[InlineData("Asterix and Obelix Slap Them All")]
[InlineData("Call of Duty - Black Ops 6")]
[InlineData("Destroy All Humans 2")]
[InlineData("Evil Genius 2 World Domination")]
[InlineData("Life is Strange Reunion Deluxe Edition v1 003 000")]
[InlineData("Life is Strange Reunion Deluxe Edition v1 003 000 PS5-UNLiMiTED")]
[InlineData("PRAGMATA")]
[InlineData("RollerCoaster Tycoon Adventures Deluxe")]
[InlineData("Two Point Museum")]
public void ProductionPs5TitlesHaveProvenanceBackedRetailFallbacks(string title)
{
var source = Assert.IsType<RetailArtworkSource>(GameArtworkEnricher.FindCuratedRetailArtwork(title, "ps5"));
Assert.Equal("WorldOfGames", source.Provider);
Assert.Equal("curated-retailer-platform-box-front", source.MatchMethod);
Assert.StartsWith("https://www.wog.ch/en/index.cfm/details/product/", source.ExternalId);
}
[Fact]
public void CuratedFallbackDoesNotCrossPlatformBoundaries() =>
Assert.Null(GameArtworkEnricher.FindCuratedRetailArtwork("PRAGMATA", "windows"));
[Fact]
public void PsxSerialFallbackRetainsItsArchivalProvenance()
{
var source = Assert.IsType<RetailArtworkSource>(GameArtworkEnricher.FindCuratedRetailArtwork("Disney's A Bug's Life", "psx"));
Assert.Equal("PSXDataCenter", source.Provider);
Assert.Equal("curated-serial-platform-box-front", source.MatchMethod);
Assert.EndsWith("SCUS-94288.html", source.ExternalId);
}
[Fact]
public void DigitalOnlyWindowsReleaseIsExplicitlyNotClaimedAsRetail()
{
var source = Assert.IsType<RetailArtworkSource>(GameArtworkEnricher.FindCuratedRetailArtwork("Movies Tycoon", "windows"));
Assert.Equal("SteamDigital", source.Provider);
Assert.Equal("verified-digital-only-storefront-front", source.MatchMethod);
}
}
@@ -0,0 +1,44 @@
using Ludarium.Infrastructure;
using Ludarium.Domain;
namespace Ludarium.IntegrationTests;
public sealed class GameArtworkStoreTests
{
[Fact]
public async Task StoresValidatedArtworkAndRejectsUnknownPayloads()
{
var root = Path.Combine(Path.GetTempPath(), "ludarium-artwork-tests", Guid.NewGuid().ToString("N"));
try
{
var store = new GameArtworkStore(root);
var gameId = Guid.NewGuid();
var png = new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4 };
await using var content = new MemoryStream(png);
Assert.Equal("image/png", await store.SaveAsync(gameId, content, png.Length, CancellationToken.None, "Libretro"));
Assert.Equal("Libretro", store.GetSource(gameId));
var described = Assert.IsType<GameArtwork>(await store.DescribeAsync(gameId, "Libretro", "exact-test", Confidence.High, CancellationToken.None));
var jpeg = new byte[] { 0xff, 0xd8, 0xff, 1, 2, 3, 4 };
await using (var replacement = new MemoryStream(jpeg))
await store.SaveAsync(gameId, replacement, jpeg.Length, CancellationToken.None, "Uploaded");
var preserved = Assert.IsType<StoredGameArtwork>(await store.OpenAsync(gameId, CancellationToken.None, described.Id, false));
Assert.Equal("image/png", preserved.ContentType);
await preserved.Content.DisposeAsync();
var saved = await store.OpenAsync(gameId, CancellationToken.None);
Assert.NotNull(saved);
Assert.Equal("image/jpeg", saved.ContentType);
Assert.Equal(jpeg.Length, saved.Length);
await saved.Content.DisposeAsync();
Assert.True(store.Delete(gameId));
Assert.False(store.Delete(gameId));
await using var invalid = new MemoryStream("not-an-image"u8.ToArray());
await Assert.ThrowsAsync<ArtworkValidationException>(() =>
store.SaveAsync(Guid.NewGuid(), invalid, invalid.Length, CancellationToken.None));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
}
@@ -0,0 +1,96 @@
using Ludarium.Infrastructure;
using System.Net;
using System.Text;
using System.Text.Json;
namespace Ludarium.IntegrationTests;
public sealed class GameCheatCatalogTests
{
[Fact]
public void ParsesBoundedDescriptionAndCodePairs()
{
const string content = """
cheats = 3
cheat0_desc = "Infinite lives"
cheat0_code = "1234:09"
cheat0_enable = false
cheat1_desc = "Unlock world"
cheat1_code = "ABCD+EFGH"
cheat2_desc = "Missing code is ignored"
""";
var cheats = GameCheatCatalog.Parse(content);
Assert.Equal(2, cheats.Count);
Assert.Equal(new GameCheat("Infinite lives", "1234:09"), cheats[0]);
Assert.Equal(new GameCheat("Unlock world", "ABCD+EFGH"), cheats[1]);
}
[Fact]
public async Task EmptyStatusDoesNotCauseAnExternalDownload()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-cheat-tests", Guid.NewGuid().ToString("N"));
var calls = 0;
try
{
using var client = new HttpClient(new StubHandler(_ =>
{
calls++;
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
}));
using var catalog = new GameCheatCatalog(directory, client);
var status = await catalog.StatusAsync(CancellationToken.None);
Assert.False(status.Cached);
Assert.Equal(0, status.IndexedFiles);
Assert.Equal(0, calls);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task RefreshesPinnedIndexAndReturnsOnlyAnExactPlatformTitleMatch()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-cheat-tests", Guid.NewGuid().ToString("N"));
try
{
var tree = Enumerable.Range(0, 100).Select(index => new
{
path = $"cht/Nintendo - Nintendo Entertainment System/{(index == 0 ? "Fixture Game" : $"Other {index}")}.cht",
type = "blob",
size = 120,
sha = index.ToString("x40", System.Globalization.CultureInfo.InvariantCulture)
}).ToArray();
var indexJson = JsonSerializer.Serialize(new { sha = GameCheatCatalog.Commit, truncated = false, tree });
var handler = new StubHandler(request => request.RequestUri!.Host == "api.github.com"
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(indexJson) }
: new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("cheat0_desc = \"Infinite lives\"\ncheat0_code = \"1234:09\"")
});
using var catalog = new GameCheatCatalog(directory, new HttpClient(handler));
var result = await catalog.FindAsync("Fixture Game", "nes", CancellationToken.None);
Assert.True(result.Available);
Assert.Equal(GameCheatCatalog.Version, result.Version);
Assert.Equal(new GameCheat("Infinite lives", "1234:09"), Assert.Single(result.Cheats));
Assert.False((await catalog.FindAsync("Missing Game", "nes", CancellationToken.None)).Available);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
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,156 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class GameDataVaultFileStoreTests
{
[Fact]
public async Task OpaqueRevisionIsHashedIdempotentDownloadableAndDeletable()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var gameId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var revisionId = Guid.NewGuid();
var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE\0slot=1\0progress=42");
var writes = await Task.WhenAll(
store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(payload),
payload.Length, "integration-test", CancellationToken.None),
store.SaveAsync(gameId, entryId, revisionId, "slot-1.sav", new MemoryStream(payload),
payload.Length, "integration-test", CancellationToken.None));
var revision = writes[0];
var retried = writes[1];
Assert.Equal(revision.Sha256, retried.Sha256);
Assert.Equal(64, revision.Sha256.Length);
Assert.Single(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories));
var opened = Assert.IsType<StoredGameDataRevision>(await store.OpenAsync(revision, CancellationToken.None));
await using (opened.Content)
{
using var copy = new MemoryStream();
await opened.Content.CopyToAsync(copy);
Assert.Equal(payload, copy.ToArray());
}
var conflictingPayload = Enumerable.Repeat((byte)0x5A, payload.Length).ToArray();
await Assert.ThrowsAsync<ResourceConflictException>(() => store.SaveAsync(gameId, entryId,
revisionId, "slot-1.sav", new MemoryStream(conflictingPayload), conflictingPayload.Length,
"integration-test", CancellationToken.None));
Assert.True(store.Delete(revision));
Assert.Null(await store.OpenAsync(revision, CancellationToken.None));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Theory]
[InlineData("MZsynthetic")]
[InlineData("#!/bin/sh")]
public async Task ExecutableAndScriptSignaturesAreRejected(string value)
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var payload = Encoding.ASCII.GetBytes(value);
await Assert.ThrowsAsync<GameDataVaultValidationException>(() => store.SaveAsync(Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(payload), payload.Length,
"integration-test", CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task SameLengthCorruptionFailsClosedOnOpen()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE-CONTENT");
var revision = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav",
new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None);
var path = Path.Combine(directory, revision.Location.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllBytesAsync(path, Enumerable.Repeat((byte)0x41, payload.Length).ToArray());
await Assert.ThrowsAsync<GameDataIntegrityException>(() => store.OpenAsync(revision, CancellationToken.None));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task DeclaredOversizeAndNativeExecutableSignaturesAreRejected()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
await Assert.ThrowsAsync<GameDataVaultValidationException>(() => store.SaveAsync(Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream([0x01]),
GameDataVaultFileStore.MaximumBytes + 1, "integration-test", CancellationToken.None));
byte[][] signatures =
[
[0x7F, 0x45, 0x4C, 0x46, 0x00],
[0xFE, 0xED, 0xFA, 0xCF, 0x00],
[0xCA, 0xFE, 0xBA, 0xBE, 0x00]
];
foreach (var signature in signatures)
await Assert.ThrowsAsync<GameDataVaultValidationException>(() => store.SaveAsync(Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(), "slot.sav", new MemoryStream(signature), signature.Length,
"integration-test", CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(directory, "*.bin", SearchOption.AllDirectories));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task ReconciliationRemovesOnlyAppOwnedIncompleteAndOrphanRevisionFiles()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-game-data-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameDataVaultFileStore(directory);
var payload = Encoding.ASCII.GetBytes("SYNTHETIC-SAVE-CONTENT");
var revision = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "slot.sav",
new MemoryStream(payload), payload.Length, "integration-test", CancellationToken.None);
var orphan = Path.Combine(directory, "orphan", "revision.bin");
var uploading = Path.Combine(directory, "orphan", "revision.uploading");
var unrelated = Path.Combine(directory, "orphan", "operator-note.txt");
Directory.CreateDirectory(Path.GetDirectoryName(orphan)!);
await File.WriteAllBytesAsync(orphan, [1, 2, 3]);
await File.WriteAllBytesAsync(uploading, [4, 5]);
await File.WriteAllTextAsync(unrelated, "retain");
var result = store.Reconcile(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { revision.Location });
Assert.Equal(1, result.RemovedUploads);
Assert.Equal(1, result.RemovedOrphans);
Assert.Equal(5, result.ReclaimedBytes);
Assert.True(File.Exists(Path.Combine(directory, revision.Location.Replace('/', Path.DirectorySeparatorChar))));
Assert.True(File.Exists(unrelated));
Assert.False(File.Exists(orphan));
Assert.False(File.Exists(uploading));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
}
@@ -0,0 +1,121 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// The library browser's query, executed by the real database.
/// </summary>
/// <remarks>
/// The library used to sort the forty-eight games already on screen, which reordered a page instead
/// of the collection, and it could not filter on the favourites, status, ratings or tags it lets an
/// operator set. Both are properties of the query, so both are proven here against PostgreSQL.
/// </remarks>
public sealed class GameLibraryQueryTests
{
[Fact]
[Trait("Category", "Container")]
public async Task LibraryFiltersAndOrderingAreAnsweredByTheDatabase()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_query_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Query fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-query-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var alpha = await store.CreateGameAsync("Alpha Adventure", "integration-test", CancellationToken.None);
var bravo = await store.CreateGameAsync("Bravo Blast", "integration-test", CancellationToken.None);
var charlie = await store.CreateGameAsync("Charlie Chase", "integration-test", CancellationToken.None);
await store.SaveGameUserStateAsync(alpha.Id,
new(true, GamePlayStatus.Playing, 5, null, 40m, null, 3, DateTimeOffset.UtcNow.AddDays(-1)),
0, "integration-test", CancellationToken.None);
await store.SaveGameUserStateAsync(bravo.Id,
new(false, GamePlayStatus.Completed, 3, null, 100m, null, 9, DateTimeOffset.UtcNow),
0, "integration-test", CancellationToken.None);
await store.AddGameTagAsync(charlie.Id, "Co-op", "integration-test", CancellationToken.None);
// --- filters ---------------------------------------------------------------------------
var favourites = await store.SearchGamesAsync(new GameQuery { Favorite = true }, CancellationToken.None);
Assert.Equal("Alpha Adventure", Assert.Single(favourites.Items).Title);
// The total describes the filtered population, not the whole catalog.
Assert.Equal(1, favourites.Total);
Assert.Equal(3, (await store.SearchGamesAsync(new GameQuery(), CancellationToken.None)).Total);
Assert.Equal("Alpha Adventure", Assert.Single((await store.SearchGamesAsync(
new GameQuery { Status = GamePlayStatus.Playing }, CancellationToken.None)).Items).Title);
Assert.Equal("Bravo Blast", Assert.Single((await store.SearchGamesAsync(
new GameQuery { Status = GamePlayStatus.Completed }, CancellationToken.None)).Items).Title);
Assert.Equal("Alpha Adventure", Assert.Single((await store.SearchGamesAsync(
new GameQuery { MinimumRating = 4 }, CancellationToken.None)).Items).Title);
Assert.Equal(2, (await store.SearchGamesAsync(new GameQuery { MinimumRating = 3 }, CancellationToken.None)).Total);
Assert.Equal("Charlie Chase", Assert.Single((await store.SearchGamesAsync(
new GameQuery { Tag = "Co-op" }, CancellationToken.None)).Items).Title);
var collection = await store.CreateCollectionAsync(new("Weekend picks", null, CollectionKind.Static, null, false),
"integration-test", CancellationToken.None);
await store.AddCollectionGameAsync(collection.Id, bravo.Id, "integration-test", CancellationToken.None);
Assert.Equal("Bravo Blast", Assert.Single((await store.SearchGamesAsync(
new GameQuery { CollectionId = collection.Id }, CancellationToken.None)).Items).Title);
// --- ordering --------------------------------------------------------------------------
var byRating = await store.SearchGamesAsync(new GameQuery { Sort = "rating" }, CancellationToken.None);
Assert.Equal(["Alpha Adventure", "Bravo Blast", "Charlie Chase"], byRating.Items.Select(item => item.Title));
var byPlayed = await store.SearchGamesAsync(new GameQuery { Sort = "played" }, CancellationToken.None);
Assert.Equal(["Bravo Blast", "Alpha Adventure", "Charlie Chase"], byPlayed.Items.Select(item => item.Title));
// A page boundary cannot change the answer, which page-local sorting could not guarantee.
var firstPage = await store.SearchGamesAsync(new GameQuery { Sort = "title", PageSize = 1 }, CancellationToken.None);
var secondPage = await store.SearchGamesAsync(new GameQuery { Sort = "title", PageSize = 1, Page = 2 }, CancellationToken.None);
Assert.Equal("Alpha Adventure", Assert.Single(firstPage.Items).Title);
Assert.Equal("Bravo Blast", Assert.Single(secondPage.Items).Title);
Assert.Equal(3, firstPage.Total);
// --- playable copies -------------------------------------------------------------------
Assert.Empty((await store.SearchGamesAsync(new GameQuery { PlayableOnly = true }, CancellationToken.None)).Items);
await LinkArtifactAsync(store, dataSource, library.Id, charlie.Id, "roms/snes/Charlie Chase.sfc", "snes");
await LinkArtifactAsync(store, dataSource, library.Id, bravo.Id, "roms/snes/Bravo Blast.txt", "snes");
// Only the allowlisted platform-and-container pair counts as a playable copy.
Assert.Equal("Charlie Chase", Assert.Single((await store.SearchGamesAsync(
new GameQuery { PlayableOnly = true }, CancellationToken.None)).Items).Title);
// --- scoped releases -------------------------------------------------------------------
await store.CreateReleaseAsync(alpha.Id, "Alpha Adventure", "snes", null, null, "integration-test", CancellationToken.None);
await store.CreateReleaseAsync(bravo.Id, "Bravo Blast", "snes", null, null, "integration-test", CancellationToken.None);
Assert.Equal("Alpha Adventure", Assert.Single(
await store.ListReleasesForGamesAsync([alpha.Id], CancellationToken.None)).Title);
Assert.Empty(await store.ListReleasesForGamesAsync([], CancellationToken.None));
Assert.Equal(2, (await store.ListReleasesAsync(null, CancellationToken.None)).Count);
}
private static async Task LinkArtifactAsync(PostgresStore store, NpgsqlDataSource dataSource,
Guid libraryId, Guid gameId, string relativePath, string platform)
{
var artifact = new Artifact(Guid.NewGuid(), libraryId, relativePath, 4 * 1024 * 1024,
DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, platform);
await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None);
await using var link = dataSource.CreateCommand("""
INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at)
VALUES($1,$2,'integration-fixture',now())
""");
link.Parameters.AddWithValue(gameId);
link.Parameters.AddWithValue(artifact.Id);
await link.ExecuteNonQueryAsync();
}
}
@@ -0,0 +1,50 @@
using System.Text;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class GameMediaStoreTests
{
[Fact]
public async Task AppOwnedManualIsBoundedHashedAndDeletable()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-media-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameMediaStore(directory);
var bytes = Encoding.ASCII.GetBytes("%PDF-1.7\n% deterministic synthetic fixture\n");
var media = await store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(), GameMediaKind.Manual,
"Synthetic manual", new MemoryStream(bytes), bytes.Length, CancellationToken.None);
Assert.Equal("application/pdf", media.ContentType);
Assert.True(media.AppOwned);
Assert.Equal(64, media.Sha256?.Length);
var opened = Assert.IsType<StoredGameMedia>(await store.OpenAsync(media, CancellationToken.None));
await using (opened.Content) Assert.Equal(bytes.Length, opened.Length);
Assert.True(store.Delete(media));
Assert.Null(await store.OpenAsync(media, CancellationToken.None));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
[Fact]
public async Task ExecutableBytesAreNeverAcceptedAsMedia()
{
var directory = Path.Combine(Path.GetTempPath(), "ludarium-media-" + Guid.NewGuid().ToString("N"));
try
{
var store = new GameMediaStore(directory);
var executable = new byte[] { 0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00 };
await Assert.ThrowsAsync<MediaValidationException>(() => store.SaveAsync(Guid.NewGuid(), Guid.NewGuid(),
GameMediaKind.Manual, "Do not execute", new MemoryStream(executable), executable.Length, CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
}
@@ -0,0 +1,31 @@
<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="SSH.NET" Version="2026.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.13.0" />
<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.Infrastructure\Ludarium.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Api\Ludarium.Api.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\fixtures\providers\**\*" Link="fixtures\providers\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using System.IO.Compression;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class N64ZipRomTests
{
[Theory]
[InlineData("game.z64", "80371240")]
[InlineData("game.v64", "37804012")]
[InlineData("game.n64", "40123780")]
public async Task SingleBoundedRomWithVerifiedMagicStreamsWithoutExtraction(string name, string magic)
{
await using var archive = Zip((name, Convert.FromHexString(magic + "01020304")));
Assert.True(await N64ZipRom.ValidateAsync(archive, CancellationToken.None));
archive.Position = 0;
var opened = await N64ZipRom.OpenAsync(archive, CancellationToken.None);
await using var content = opened.Content;
var bytes = new byte[8];
await content.ReadExactlyAsync(bytes);
Assert.Equal(name, opened.FileName);
Assert.Equal(8, opened.Length);
Assert.Equal(Convert.FromHexString(magic), bytes[..4]);
Assert.False(content.CanSeek);
}
[Fact]
public async Task MultipleRomEntriesAndInvalidMagicFailClosed()
{
await using var multiple = Zip(("one.z64", Convert.FromHexString("8037124001")),
("two.v64", Convert.FromHexString("3780401202")));
await using var invalid = Zip(("game.z64", Convert.FromHexString("0000000001")));
Assert.False(await N64ZipRom.ValidateAsync(multiple, CancellationToken.None));
Assert.False(await N64ZipRom.ValidateAsync(invalid, CancellationToken.None));
}
private static MemoryStream Zip(params (string Name, byte[] Content)[] entries)
{
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
foreach (var item in entries)
{
var entry = archive.CreateEntry(item.Name, CompressionLevel.SmallestSize);
using var content = entry.Open();
content.Write(item.Content);
}
stream.Position = 0;
return stream;
}
}
@@ -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;
}
}
}
@@ -0,0 +1,56 @@
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class NativeProviderFixtureTests
{
private static readonly Game Sonic = new(Guid.Parse("d2e86fb8-2184-4d2d-bce7-cb3156f0b9b2"),
"Sonic the Hedgehog", DateTimeOffset.UnixEpoch);
private static readonly Release Genesis = new(Guid.Parse("dc2ad40a-0d30-4eeb-8f32-25a273c14833"),
Sonic.Id, Sonic.Title, "genesis", "world", null, DateTimeOffset.UnixEpoch);
[Fact]
public void IgdbFixtureProducesProvenancedMetadataAndArtwork()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
IgdbMetadataProvider.Parse(Read("igdb-game.json"), Sonic, Genesis));
Assert.Equal("1074", result.ExternalId);
Assert.Contains(result.Fields, item => item.Field == "developer" && item.Value == "Sonic Team");
Assert.Contains(result.Artwork, item => item.Url.StartsWith("https://images.igdb.com/", StringComparison.Ordinal));
}
[Fact]
public void MobyGamesFixtureRequiresAnExactTitleAndReturnsHttpsArtwork()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
MobyGamesMetadataProvider.Parse(Read("mobygames-game.json"), Sonic, Genesis));
Assert.Equal("6225", result.ExternalId);
Assert.All(result.Artwork, item => Assert.StartsWith("https://", item.Url, StringComparison.Ordinal));
Assert.Null(MobyGamesMetadataProvider.Parse(Read("mobygames-game.json"),
Sonic with { Title = "Sonic 2" }, Genesis));
}
[Fact]
public void ScreenScraperFixtureIsParsedWithoutExternalEntities()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
ScreenScraperMetadataProvider.Parse(Read("screenscraper-game.xml"), Sonic, Genesis));
Assert.Equal("1", result.ExternalId);
Assert.Contains(result.Fields, item => item.Field == "players" && item.Value == "2");
}
[Fact]
public void RetroAchievementsFixturePreservesUnlockStateAndDefinitions()
{
var result = Assert.IsType<Ludarium.Application.ProviderGameResult>(
RetroAchievementsMetadataProvider.ParseDetails(Read("retroachievements-game.json"), Sonic, Genesis, 1));
var progress = Assert.IsType<GameAchievementProgress>(result.AchievementProgress);
Assert.Equal(2, progress.Total);
Assert.Equal(1, progress.Earned);
Assert.Single(progress.Achievements, item => item.Unlocked);
}
private static string Read(string name) => File.ReadAllText(Path.Combine(AppContext.BaseDirectory,
"fixtures", "providers", name));
}
@@ -0,0 +1,619 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
public sealed class PostgresStoreTests
{
[Fact]
[Trait("Category", "Container")]
public async Task MigrationsPersistenceIdempotencyAndOptimisticConcurrencyWorkAgainstPostgres()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
await store.InitializeAsync(CancellationToken.None);
Assert.Equal(PostgresStore.CurrentSchemaVersion, await store.GetSchemaVersionAsync(CancellationToken.None));
var emptySnapshot = await Assert.ThrowsAsync<SnapshotPreconditionException>(() =>
store.CreateSnapshotAsync("empty baseline", CancellationToken.None));
Assert.Contains("at least one present file with a SHA-256 hash", emptySnapshot.Message);
Assert.Empty(await store.ListSnapshotsAsync(CancellationToken.None));
await using (var versions = dataSource.CreateCommand("SELECT array_agg(version ORDER BY version) FROM schema_versions"))
{
var appliedVersions = Assert.IsType<int[]>(await versions.ExecuteScalarAsync());
Assert.Collection(appliedVersions,
value => Assert.Equal(1, value), value => Assert.Equal(2, value),
value => Assert.Equal(3, value), value => Assert.Equal(4, value),
value => Assert.Equal(5, value), value => Assert.Equal(6, value),
value => Assert.Equal(7, value), value => Assert.Equal(8, value),
value => Assert.Equal(9, value), value => Assert.Equal(10, value),
value => Assert.Equal(11, value), value => Assert.Equal(12, value),
value => Assert.Equal(13, value), value => Assert.Equal(14, value),
value => Assert.Equal(15, value), value => Assert.Equal(16, value),
value => Assert.Equal(17, value), value => Assert.Equal(18, value),
value => Assert.Equal(19, value), value => Assert.Equal(20, value),
value => Assert.Equal(21, value), value => Assert.Equal(22, value),
value => Assert.Equal(23, value), value => Assert.Equal(24, value),
value => Assert.Equal(25, value), value => Assert.Equal(26, value),
value => Assert.Equal(27, value));
}
var firmware = new FirmwareAsset(Guid.NewGuid(), "psx", "bios", FirmwareAssetKind.Bios,
"personal.bin", 512, new string('f', 64), "psx/bios/personal.bin", true,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
await store.SaveFirmwareAssetAsync(firmware, "tester", CancellationToken.None);
Assert.Equal(firmware.Id, (await store.GetSelectedFirmwareAssetAsync("psx", "bios", CancellationToken.None))?.Id);
Assert.Contains(await store.ListFirmwareAssetsAsync(CancellationToken.None), item => item.Sha256 == firmware.Sha256);
var removedFirmware = await store.DeleteFirmwareAssetAsync(firmware.Id, firmware.Version, "tester", CancellationToken.None);
Assert.Single(removedFirmware);
Assert.Null(await store.GetSelectedFirmwareAssetAsync("psx", "bios", CancellationToken.None));
var library = LibraryRoot.Create("Synthetic", Path.GetFullPath(Path.Combine(Path.GetTempPath(),
"ludarium-integration-library")), LibraryKind.Mixed) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var firstScan = await store.CreateScanAsync(library.Id, ScanMode.Deep, "stable-request", CancellationToken.None);
var repeatedScan = await store.CreateScanAsync(library.Id, ScanMode.Deep, "stable-request", CancellationToken.None);
Assert.Equal(firstScan.Id, repeatedScan.Id);
var content = new ContentBlob(Guid.NewGuid(), 4, new string('a', 64), new string('b', 40), "1234abcd", DateTimeOffset.UtcNow);
var artifact = new Artifact(Guid.NewGuid(), library.Id, "roms/nes/Synthetic.nes", 4, DateTimeOffset.UtcNow,
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, content.Id,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "iNES", "nes", Sha256: content.Sha256);
var evidence = new Evidence("signature", "iNES", "test", "1", Confidence.Deterministic, DateTimeOffset.UtcNow);
await store.UpsertArtifactAsync(artifact, content, [evidence], CancellationToken.None);
await store.UpsertArtifactAsync(artifact with { Version = 2 }, content, [evidence], CancellationToken.None);
var persisted = await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None);
Assert.NotNull(persisted);
Assert.Single(persisted.Evidence);
Assert.Equal(content.Sha256, persisted.Artifact.Sha256);
var integritySnapshot = await store.CreateSnapshotAsync("hashed baseline", CancellationToken.None);
Assert.Single(integritySnapshot.Items, item => item.ArtifactId == artifact.Id && item.Sha256 == content.Sha256);
Assert.Contains(await store.ListSnapshotsAsync(CancellationToken.None), item => item.Id == integritySnapshot.Id);
var dolphinGameId = Guid.NewGuid();
var dolphinArtifact = artifact with
{
Id = Guid.NewGuid(),
RelativePath = "roms/wii/Synthetic Fixture.rvz",
Size = 2 * 1024 * 1024,
Platform = "wii",
Signature = "RVZ",
ContentBlobId = null,
Sha256 = null,
Version = 1
};
await store.UpsertArtifactAsync(dolphinArtifact, null, [new Evidence("extension", "RVZ", "test", "1",
Confidence.Deterministic, DateTimeOffset.UtcNow)], CancellationToken.None);
// A parameterised command carries exactly one statement: PostgreSQL rejects several in the
// extended query protocol, so the fixture rows are inserted separately.
await using (var insertGame = dataSource.CreateCommand("""
INSERT INTO games(id,title,version,data)
VALUES($1,$2,1,jsonb_build_object('id',$1,'title',$2,'createdAt',now(),'version',1))
"""))
{
insertGame.Parameters.AddWithValue(dolphinGameId);
insertGame.Parameters.AddWithValue("Synthetic Dolphin Fixture");
await insertGame.ExecuteNonQueryAsync();
}
await using (var link = dataSource.CreateCommand("""
INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at)
VALUES($1,$2,'integration-fixture',now())
"""))
{
link.Parameters.AddWithValue(dolphinGameId);
link.Parameters.AddWithValue(dolphinArtifact.Id);
await link.ExecuteNonQueryAsync();
}
var dolphinCandidate = Assert.Single(await store.ListDolphinLaunchCandidatesAsync(dolphinGameId, "wii",
CancellationToken.None));
Assert.Equal(dolphinArtifact.RelativePath, dolphinCandidate.RelativePath);
Assert.True(dolphinCandidate.SourceReadOnly);
Assert.Empty(await store.ListDolphinLaunchCandidatesAsync(dolphinGameId, "xbox", CancellationToken.None));
var titledArtifact = artifact with
{
Id = Guid.NewGuid(),
RelativePath = "roms/nes/Super.Game.64 (USA).nes",
ContentBlobId = null,
Sha256 = null,
Version = 1
};
await store.UpsertArtifactAsync(titledArtifact, null, [evidence], CancellationToken.None);
var legacyGameId = Guid.NewGuid();
await using (var legacy = dataSource.CreateCommand("INSERT INTO games(id,title,version,data) VALUES($1,$2,1,jsonb_build_object('id',$1,'title',$2,'createdAt',now(),'version',1))"))
{
legacy.Parameters.AddWithValue(legacyGameId);
legacy.Parameters.AddWithValue("0002 - Legacy Racer (USA) zip");
await legacy.ExecuteNonQueryAsync();
}
await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None);
var canonicalGame = Assert.Single((await store.SearchGamesAsync(new GameQuery { Search = "Super Game 64", Platform = "nes", Page = 1, PageSize = 25 }, CancellationToken.None)).Items);
Assert.Equal("Super Game 64", canonicalGame.Title);
Assert.Equal(GameOrigin.Scan, canonicalGame.Origin);
await Assert.ThrowsAsync<ResourceConflictException>(() => store.DeleteGameAsync(canonicalGame.Id,
canonicalGame.Version, "integration-test", CancellationToken.None));
var browserCandidate = Assert.IsType<BrowserPlayCandidate>(
await store.GetBrowserPlayCandidateAsync(canonicalGame.Id, CancellationToken.None));
Assert.Equal(titledArtifact.Id, browserCandidate.ArtifactId);
Assert.True(browserCandidate.SourceReadOnly);
var playSession = new BrowserPlaySession(Guid.NewGuid(), canonicalGame.Id, titledArtifact.Id,
PlaySessionState.Ready, "nes", "Ludarium EmulatorJS", "fceumm", "/player.html",
new string('d', 64), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(30));
await store.SaveBrowserPlaySessionAsync(playSession, "integration-test", CancellationToken.None);
Assert.Equal(1, await store.CountActiveBrowserPlaySessionsAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Equal(playSession.Id, (await store.GetBrowserPlaySessionAsync(playSession.Id, CancellationToken.None))?.Id);
await store.EndBrowserPlaySessionAsync(playSession.Id, PlaySessionState.Cancelled, "integration-test",
"cancelled", "Synthetic cancellation.", CancellationToken.None);
Assert.Equal(PlaySessionState.Cancelled,
(await store.GetBrowserPlaySessionAsync(playSession.Id, CancellationToken.None))?.State);
await using (var playAudit = dataSource.CreateCommand(
"SELECT string_agg(data::text,' ') FROM audit_events WHERE data->>'action' LIKE 'BrowserPlay%'"))
{
var redactedAudit = Assert.IsType<string>(await playAudit.ExecuteScalarAsync());
Assert.DoesNotContain(titledArtifact.RelativePath, redactedAudit, StringComparison.Ordinal);
Assert.DoesNotContain(playSession.TokenHash, redactedAudit, StringComparison.Ordinal);
}
Assert.Equal("Legacy Racer", Assert.Single((await store.SearchGamesAsync(new GameQuery { Search = "Legacy Racer", Page = 1, PageSize = 25 }, CancellationToken.None)).Items).Title);
await store.UpsertArtifactAsync(titledArtifact with { Id = Guid.NewGuid(), RelativePath = "roms/nes/Super_Game_64 (USA).nes" }, null, [evidence], CancellationToken.None);
await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None);
Assert.Null(await store.GetBrowserPlayCandidateAsync(canonicalGame.Id, CancellationToken.None));
Assert.Single(await store.ListReleasesAsync(canonicalGame.Id, CancellationToken.None));
var libraryHealth = Assert.Single(await store.ListLibraryHealthAsync(CancellationToken.None), item => item.LibraryId == library.Id);
Assert.True(libraryHealth.Artifacts >= 2);
// The canonical NES game, the legacy racer and the linked Dolphin fixture all live in this library.
Assert.Equal(3, libraryHealth.Games);
Assert.Contains("nes", libraryHealth.DetectedPlatforms);
var scanRelease = Assert.Single(await store.ListReleasesAsync(canonicalGame.Id, CancellationToken.None));
Assert.Equal(ReleaseOrigin.Scan, scanRelease.Origin);
await Assert.ThrowsAsync<ResourceConflictException>(() => store.UpdateReleaseAsync(scanRelease.Id,
"Unsafe override", "nes", null, null, scanRelease.Version, "integration-test", CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(() => store.DeleteReleaseAsync(scanRelease.Id,
scanRelease.Version, "integration-test", CancellationToken.None));
var artwork = titledArtifact with
{
Id = Guid.NewGuid(),
RelativePath = "roms/nes/Super Game 64 (USA)/imgs/covers/cover1.jpg",
MediaType = MediaType.SupportFile,
State = ArtifactState.Ignored
};
await store.UpsertArtifactAsync(artwork, null, [], CancellationToken.None);
await store.MarkArtifactIgnoredAsync(artwork.Id, 2, evidence, CancellationToken.None);
Assert.Equal(artwork.Id, (await store.GetGameArtworkAsync(canonicalGame.Id, CancellationToken.None))?.Artifact.Id);
var game = await store.CreateGameAsync("Synthetic Game", "integration-test", CancellationToken.None);
Assert.Equal(GameOrigin.Manual, game.Origin);
Assert.Equal(game.Id, (await store.GetGameAsync(game.Id, CancellationToken.None))?.Id);
Assert.Null(await store.GetGameAsync(Guid.NewGuid(), CancellationToken.None));
var updated = await store.UpdateGameAsync(game.Id, "Synthetic Game Updated", game.Version, "integration-test", CancellationToken.None);
var gamePage = await store.SearchGamesAsync(new GameQuery { Search = "Updated", Page = 1, PageSize = 25 }, CancellationToken.None);
Assert.Equal(1, gamePage.Total);
Assert.Equal(updated.Id, Assert.Single(gamePage.Items).Id);
await Assert.ThrowsAsync<ConcurrencyException>(() =>
store.UpdateGameAsync(game.Id, "Stale Update", game.Version, "integration-test", CancellationToken.None));
var release = await store.CreateReleaseAsync(game.Id, "Synthetic Release", "nes", "world", null, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReleasesAsync(game.Id, CancellationToken.None), item => item.Id == release.Id);
Assert.Equal(ReleaseOrigin.Manual, release.Origin);
await using (var linkPlayableGame = dataSource.CreateCommand("""
INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at)
VALUES($1,$2,'integration-fixture',now()) ON CONFLICT DO NOTHING
"""))
{
linkPlayableGame.Parameters.AddWithValue(game.Id);
linkPlayableGame.Parameters.AddWithValue(artifact.Id);
await linkPlayableGame.ExecuteNonQueryAsync();
}
var unsupportedGame = await store.CreateGameAsync("Synthetic Future Console Game", "integration-test", CancellationToken.None);
await store.CreateReleaseAsync(unsupportedGame.Id, "Synthetic Future Console Release", "future-console", null, null,
"integration-test", CancellationToken.None);
var playability = await store.GetPlayabilitySummaryAsync(CancellationToken.None);
Assert.Equal(playability.Supported, playability.Ready + playability.Blocked);
Assert.True(playability.Ready >= 1);
Assert.True(playability.Unsupported >= 1);
Assert.Contains(playability.ByPlatform, item => item.Platform == "nes" && item.Ready >= 1);
var updatedRelease = await store.UpdateReleaseAsync(release.Id, "Synthetic Release Revised", "nes", "world", "rev 2",
release.Version, "integration-test", CancellationToken.None);
Assert.Equal("rev 2", updatedRelease.Revision);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UpdateReleaseAsync(release.Id, "Stale", "nes", null, null,
release.Version, "integration-test", CancellationToken.None));
var deletedRelease = await store.CreateReleaseAsync(game.Id, "Disposable Release", "nes", null, null,
"integration-test", CancellationToken.None);
await store.DeleteReleaseAsync(deletedRelease.Id, deletedRelease.Version, "integration-test", CancellationToken.None);
Assert.DoesNotContain(await store.ListReleasesAsync(game.Id, CancellationToken.None), item => item.Id == deletedRelease.Id);
var collection = await store.CreateCollectionAsync(new("Synthetic favorites", "A deterministic set",
CollectionKind.Static, null, true), "integration-test", CancellationToken.None);
await store.AddCollectionGameAsync(collection.Id, game.Id, "integration-test", CancellationToken.None);
Assert.Equal(game.Id, Assert.Single((await store.SearchCollectionGamesAsync(collection.Id, 1, 25, CancellationToken.None)).Items).Id);
var tag = await store.AddGameTagAsync(game.Id, "Co-Op", "integration-test", CancellationToken.None);
Assert.Equal("co-op", tag.NormalizedName);
Assert.Single(await store.ListTagsAsync(CancellationToken.None), item => item.NormalizedName == "co-op" && item.GameCount == 1);
var state = await store.SaveGameUserStateAsync(game.Id, new(true, GamePlayStatus.Playing, 9, 6, 42.5m,
"Co-op campaign", 3, DateTimeOffset.UtcNow), 0, "integration-test", CancellationToken.None);
Assert.Equal(1, state.Version);
var played = await store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None);
played = await store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None);
Assert.Equal(5, played.PlayCount);
Assert.Equal(GamePlayStatus.Playing, played.Status);
Assert.NotNull(played.LastPlayedAt);
var concurrentPlays = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ =>
store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None)));
Assert.Equal(9, (await store.GetGameUserStateAsync(game.Id, CancellationToken.None)).PlayCount);
Assert.Equal(4, concurrentPlays.Select(item => item.PlayCount).Distinct().Count());
var recent = Assert.Single(await store.ListRecentlyPlayedGamesAsync(6, CancellationToken.None));
Assert.Equal(game.Id, recent.Game.Id);
Assert.Equal(9, recent.State.PlayCount);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.SaveGameUserStateAsync(game.Id,
new(false, GamePlayStatus.Backlog, null, null, null, null, 0, null), 0, "integration-test", CancellationToken.None));
var smart = await store.CreateCollectionAsync(new("Currently playing", null, CollectionKind.Smart,
new CollectionRule(Tags: ["co-op"], Status: GamePlayStatus.Playing, Favorite: true), false),
"integration-test", CancellationToken.None);
Assert.Contains((await store.SearchCollectionGamesAsync(smart.Id, 1, 25, CancellationToken.None)).Items, item => item.Id == game.Id);
var relation = await store.AddGameRelationAsync(game.Id, new(GameRelationKind.Extra, "Soundtrack", null, null),
"integration-test", CancellationToken.None);
Assert.Contains(await store.ListGameRelationsAsync(game.Id, CancellationToken.None), item => item.Id == relation.Id);
var media = await store.AddExternalGameMediaAsync(game.Id, new(GameMediaKind.Manual, "Online manual",
"https://example.test/manual.pdf", "Synthetic", "manual-1"), "integration-test", CancellationToken.None);
Assert.True(media.Selected);
var achievementProgress = new GameAchievementProgress(game.Id, "Synthetic", "game-1", 1, 1,
DateTimeOffset.UtcNow, [new(Guid.NewGuid(), "achievement-1", "First step", null, 5, true, DateTimeOffset.UtcNow, null)]);
await store.UpsertAchievementProgressAsync(achievementProgress, CancellationToken.None);
Assert.Equal(1, (await store.GetAchievementProgressAsync(game.Id, CancellationToken.None))?.Earned);
var dataEntryId = Guid.NewGuid();
var firstDataRevision = new GameDataRevision(Guid.NewGuid(), dataEntryId, 0, "slot-1.sav",
"application/octet-stream", 16, new string('a', 64),
$"{game.Id:N}/{dataEntryId:N}/{Guid.NewGuid():N}.bin", "ManualUpload", "integration-test",
DateTimeOffset.UtcNow);
firstDataRevision = firstDataRevision with
{
Location = $"{game.Id:N}/{dataEntryId:N}/{firstDataRevision.Id:N}.bin"
};
var dataInput = new GameDataEntryInput(GameDataKind.Save, "Main campaign", "mGBA", "Steam Deck",
"Before the final dungeon");
var concurrentDataWrites = await Task.WhenAll(
store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, 0, firstDataRevision,
"integration-test", CancellationToken.None),
store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, 0, firstDataRevision,
"integration-test", CancellationToken.None));
var dataItem = concurrentDataWrites[0];
Assert.Equal(1, dataItem.Entry.Version);
Assert.Equal(dataItem.Entry.Id, concurrentDataWrites[1].Entry.Id);
var secondDataRevision = firstDataRevision with
{
Id = Guid.NewGuid(),
Length = 24,
Sha256 = new string('b', 64),
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
};
secondDataRevision = secondDataRevision with
{
Location = $"{game.Id:N}/{dataEntryId:N}/{secondDataRevision.Id:N}.bin"
};
dataItem = await store.SaveGameDataRevisionAsync(game.Id, dataEntryId,
new(GameDataKind.Save, "Main campaign", "mGBA", "Steam Deck", "After the final dungeon"),
dataItem.Entry.Version, secondDataRevision, "integration-test", CancellationToken.None);
Assert.Equal(2, dataItem.Entry.RevisionCount);
var reboundDataRevision = secondDataRevision with
{
Id = Guid.NewGuid(),
SourceId = "artifact-version-2",
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(2)
};
reboundDataRevision = reboundDataRevision with
{
Location = $"{game.Id:N}/{dataEntryId:N}/{reboundDataRevision.Id:N}.bin"
};
dataItem = await store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput,
dataItem.Entry.Version, reboundDataRevision, "integration-test", CancellationToken.None);
Assert.Equal(3, dataItem.Entry.RevisionCount);
Assert.Equal("artifact-version-2", dataItem.CurrentRevision.SourceId);
Assert.Equal(3, (await store.ListGameDataRevisionsAsync(dataEntryId, 1, 25, CancellationToken.None)).Total);
var revisionLocations = await store.ListGameDataRevisionLocationsAsync(CancellationToken.None);
Assert.Contains(firstDataRevision.Location, revisionLocations);
Assert.Contains(secondDataRevision.Location, revisionLocations);
dataItem = await store.UpdateGameDataAsync(game.Id, dataEntryId,
new("Main campaign", "mGBA", "Living room handheld", "Verified post-game save"),
dataItem.Entry.Version, "integration-test", CancellationToken.None);
Assert.Equal("Living room handheld", dataItem.Entry.Device);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UpdateGameDataAsync(game.Id, dataEntryId,
new("Stale", null, null, null), 1, "integration-test", CancellationToken.None));
var dataSummary = await store.GetGameDataSummaryAsync(game.Id, CancellationToken.None);
Assert.Equal(1, dataSummary.Saves);
Assert.Equal(3, dataSummary.Revisions);
Assert.Equal(64, dataSummary.Bytes);
var customPlatform = await store.CreatePlatformDefinitionAsync(new("synthetic-console", "Synthetic Console",
"Synthetic", ["synth"], true), "integration-test", CancellationToken.None);
var disabledPlatform = await store.UpdatePlatformDefinitionAsync(customPlatform.Id,
new(customPlatform.Id, customPlatform.Name, customPlatform.Category, customPlatform.Aliases, false),
customPlatform.Version, "integration-test", CancellationToken.None);
Assert.False(disabledPlatform.Enabled);
Assert.Contains((await store.SearchGamesAsync(new GameQuery { Platform = "nes", Page = 1, PageSize = 25 }, CancellationToken.None)).Items, item => item.Id == game.Id);
Assert.Empty((await store.SearchGamesAsync(new GameQuery { Platform = "ps5", Page = 1, PageSize = 25 }, CancellationToken.None)).Items);
var input = new WishlistInput(game.Id, game.Title, "nes", WishlistPriority.High, "Find a boxed copy", WishlistStatus.WaitingForSale, 25, 20, "EUR", "Synthetic Store", "https://example.test/game", "Boxed", "EU");
var wish = await store.CreateWishlistItemAsync(input, "integration-test", CancellationToken.None);
Assert.Equal(wish.Id, Assert.Single((await store.SearchWishlistAsync("boxed", "nes", WishlistPriority.High, WishlistStatus.WaitingForSale, "price", 1, 25, CancellationToken.None)).Items).Id);
Assert.Equal(1, (await store.GetWishlistSummaryAsync(CancellationToken.None)).PriceDrops);
await Assert.ThrowsAsync<ResourceConflictException>(() => store.CreateWishlistItemAsync(input, "integration-test", CancellationToken.None));
var updatedInput = input with { Priority = WishlistPriority.Low, Notes = "Wait for a sale", CurrentPrice = 30 };
var updatedWish = await store.UpdateWishlistItemAsync(wish.Id, updatedInput, wish.Version, "integration-test", CancellationToken.None);
Assert.Equal(WishlistPriority.Low, updatedWish.Priority);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UpdateWishlistItemAsync(wish.Id, input, wish.Version, "integration-test", CancellationToken.None));
await store.DeleteWishlistItemAsync(updatedWish.Id, updatedWish.Version, "integration-test", CancellationToken.None);
var futureWish = await store.CreateWishlistItemAsync(new(null, "Unique Future Game", "ps5", WishlistPriority.Normal, null), "integration-test", CancellationToken.None);
var uniqueFutureGame = await store.CreateGameAsync("Unique Future Game", "integration-test", CancellationToken.None);
Assert.Equal(1, await store.ReconcileWishlistAsync("integration-test", CancellationToken.None));
var reconciled = Assert.Single((await store.SearchWishlistAsync("Unique Future Game", null, null, WishlistStatus.Acquired, null, 1, 25, CancellationToken.None)).Items);
Assert.Equal(uniqueFutureGame.Id, reconciled.GameId);
await store.DeleteWishlistItemAsync(reconciled.Id, reconciled.Version, "integration-test", CancellationToken.None);
var ambiguousWish = await store.CreateWishlistItemAsync(new(null, "Ambiguous Future Game", null, WishlistPriority.Normal, null), "integration-test", CancellationToken.None);
await store.CreateGameAsync("Ambiguous Future Game", "integration-test", CancellationToken.None);
await store.CreateGameAsync("Ambiguous Future Game", "integration-test", CancellationToken.None);
Assert.Equal(0, await store.ReconcileWishlistAsync("integration-test", CancellationToken.None));
var stillUnlinked = Assert.Single((await store.SearchWishlistAsync("Ambiguous Future Game", null, null, WishlistStatus.Interested, null, 1, 25, CancellationToken.None)).Items);
Assert.Null(stillUnlinked.GameId);
await store.DeleteWishlistItemAsync(stillUnlinked.Id, stillUnlinked.Version, "integration-test", CancellationToken.None);
Assert.Empty((await store.SearchWishlistAsync(null, null, null, null, null, 1, 25, CancellationToken.None)).Items);
var visiblePage = await store.SearchGamesAsync(new GameQuery { Page = 1, PageSize = 200 }, CancellationToken.None);
var dashboard = await store.GetDashboardSummaryAsync(CancellationToken.None);
Assert.Equal(visiblePage.Total, dashboard.Games);
var dormant = LibraryRoot.Create("Dormant", Path.GetFullPath(Path.Combine(Path.GetTempPath(),
"ludarium-dormant-library")), LibraryKind.Mixed) with
{ Enabled = false, IsReadOnly = false, IsAvailable = false, HasEntries = false };
await store.UpsertLibraryAsync(dormant, CancellationToken.None);
Assert.Equal(dashboard.RootsRequiringAttention,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).RootsRequiringAttention);
var nesPlatform = Assert.Single(await store.ListPlatformsAsync(CancellationToken.None), item => item.Platform == "nes");
Assert.Equal((await store.SearchGamesAsync(new GameQuery { Platform = "nes", Page = 1, PageSize = 200 }, CancellationToken.None)).Total, nesPlatform.Games);
var hiddenSupportGame = await store.CreateGameAsync("Steam", "integration-test", CancellationToken.None);
Assert.Null(await store.GetGameAsync(hiddenSupportGame.Id, CancellationToken.None));
var supportAwareDashboard = await store.GetDashboardSummaryAsync(CancellationToken.None);
Assert.Equal(visiblePage.Total, supportAwareDashboard.Games);
Assert.Equal(1, supportAwareDashboard.ExcludedSupportGames);
var defaults = await store.GetOperationalSettingsAsync(CancellationToken.None);
var settings = await store.SaveOperationalSettingsAsync(defaults with { DefaultPageSize = 100 }, defaults.Version, CancellationToken.None);
Assert.Equal(100, settings.DefaultPageSize);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.SaveOperationalSettingsAsync(defaults, defaults.Version, CancellationToken.None));
var artworkRecord = new GameArtwork(Guid.NewGuid(), game.Id, ArtworkRole.VerifiedBoxFront,
ArtworkVerificationStatus.AutomaticallyVerified, "Synthetic", "fixture", "art-1", "exact-test",
Confidence.Deterministic, 600, 900, 0.6667m, 1234, new string('c', 64), 100m, DateTimeOffset.UtcNow);
await store.UpsertGameArtworkAsync(artworkRecord, CancellationToken.None);
Assert.Equal(artworkRecord.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id);
var alternateArtwork = artworkRecord with { Id = Guid.NewGuid(), Source = "Alternate", QualityScore = 82m, Selected = false };
await store.UpsertGameArtworkAsync(alternateArtwork, CancellationToken.None);
Assert.Equal(2, (await store.ListGameArtworkAsync(game.Id, CancellationToken.None)).Count);
await store.SelectGameArtworkAsync(game.Id, alternateArtwork.Id, CancellationToken.None);
Assert.Equal(alternateArtwork.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id);
await store.SelectGameArtworkAsync(game.Id, artworkRecord.Id, CancellationToken.None);
Assert.Equal(artworkRecord.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id);
Assert.Single(await store.ListGameArtworkAsync(game.Id, CancellationToken.None), item => item.Selected);
var ps5Artwork = artworkRecord with { Id = Guid.NewGuid(), Platform = "ps5" };
var windowsArtwork = artworkRecord with { Id = Guid.NewGuid(), Platform = "windows" };
await store.UpsertGameArtworkAsync(ps5Artwork, CancellationToken.None);
await store.UpsertGameArtworkAsync(windowsArtwork, CancellationToken.None);
Assert.Equal(ps5Artwork.Id,
(await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None, "ps5"))?.Id);
Assert.Equal(windowsArtwork.Id,
(await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None, "windows"))?.Id);
Assert.Equal(3, (await store.ListGameArtworkAsync(game.Id, CancellationToken.None)).Count(item => item.Selected));
await Assert.ThrowsAsync<KeyNotFoundException>(() => store.SelectGameArtworkAsync(game.Id, Guid.NewGuid(), CancellationToken.None));
Assert.DoesNotContain(await store.ListArtworkNeedingReviewAsync(CancellationToken.None), item => item.Id == artworkRecord.Id);
var disposableGame = await store.CreateGameAsync("Disposable Manual Game", "integration-test", CancellationToken.None);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.DeleteGameAsync(disposableGame.Id,
disposableGame.Version - 1, "integration-test", CancellationToken.None));
await store.CreateReleaseAsync(disposableGame.Id, "Disposable Version", "nes", null, null,
"integration-test", CancellationToken.None);
await store.AddGameTagAsync(disposableGame.Id, "Disposable", "integration-test", CancellationToken.None);
var retainedWish = await store.CreateWishlistItemAsync(new(disposableGame.Id, disposableGame.Title, "nes",
WishlistPriority.Normal, "Retain this intent"), "integration-test", CancellationToken.None);
var deletion = await store.DeleteGameAsync(disposableGame.Id, disposableGame.Version,
"integration-test", CancellationToken.None);
Assert.Equal(disposableGame.Id, deletion.Game.Id);
Assert.DoesNotContain(await store.ListGamesAsync(CancellationToken.None), item => item.Id == disposableGame.Id);
Assert.Empty(await store.ListReleasesAsync(disposableGame.Id, CancellationToken.None));
var standaloneWish = Assert.Single((await store.SearchWishlistAsync("Disposable Manual Game", null, null,
null, null, 1, 25, CancellationToken.None)).Items);
Assert.Equal(retainedWish.Id, standaloneWish.Id);
Assert.Null(standaloneWish.GameId);
Assert.Equal(retainedWish.Version + 1, standaloneWish.Version);
await store.DeleteWishlistItemAsync(standaloneWish.Id, standaloneWish.Version, "integration-test", CancellationToken.None);
var queuedJob = await store.EnqueueJobAsync("Synthetic", "{}", "synthetic-job", CancellationToken.None);
Assert.Equal(queuedJob.Id, (await store.EnqueueJobAsync("Synthetic", "{}", "synthetic-job", CancellationToken.None)).Id);
var leasedJob = Assert.IsType<BackgroundJob>(await store.LeaseJobAsync("integration-worker", TimeSpan.FromMinutes(1), CancellationToken.None));
Assert.Equal(JobState.Leased, leasedJob.State);
await store.UpdateJobAsync(leasedJob with { State = JobState.Completed, LeaseOwner = null, LeaseUntil = null }, CancellationToken.None);
Assert.Contains(await store.ListJobsAsync(CancellationToken.None), item => item.Id == queuedJob.Id && item.State == JobState.Completed);
var openReviewsBeforeLifecycle = (await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews;
var review = new ReviewItem(Guid.NewGuid(), "Synthetic ambiguity", Severity.Notice, ReviewState.Open,
"{}", DateTimeOffset.UtcNow);
await store.AddReviewAsync(review, CancellationToken.None);
Assert.Equal(openReviewsBeforeLifecycle + 1,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews);
var reviewGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.ReviewIds.Contains(review.Id));
var filteredReviewGroups = await store.SearchReviewGroupsAsync(new ReviewGroupQuery
{
State = ReviewState.Open,
Search = "Synthetic ambiguity",
Severity = Severity.Notice,
Order = ReviewGroupOrder.Newest,
Page = 1,
PageSize = 1
}, CancellationToken.None);
Assert.Contains(filteredReviewGroups.Items, group => group.ReviewIds.Contains(review.Id));
Assert.True(filteredReviewGroups.Total >= 1);
Assert.Equal(0, (await store.SearchReviewGroupsAsync(new ReviewGroupQuery
{
State = ReviewState.Open,
Search = "no-such-review-group",
Page = 1,
PageSize = 50
}, CancellationToken.None)).Total);
var operation = await store.ResolveReviewGroupAsync(reviewGroup.ReviewIds, (int)reviewGroup.Count,
"accepted exact test group", "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved);
await store.UndoReviewOperationAsync(operation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open);
var deferredOperation = await store.DeferReviewGroupAsync(reviewGroup.ReviewIds, (int)reviewGroup.Count,
"deferred exact test group", "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Deferred);
Assert.Equal(openReviewsBeforeLifecycle,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews);
var deferredGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Deferred, CancellationToken.None),
group => group.ReviewIds.Contains(review.Id));
var reopenedOperation = await store.ReopenReviewGroupAsync(deferredGroup.ReviewIds, (int)deferredGroup.Count, ReviewState.Deferred,
"reopened exact test group", "integration-test", CancellationToken.None);
var reopenedReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id);
Assert.Equal(ReviewState.Open, reopenedReview.State);
Assert.Null(reopenedReview.Resolution);
Assert.Equal(openReviewsBeforeLifecycle + 1,
(await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews);
var resolvedOperation = await store.ResolveReviewGroupAsync(reopenedOperation.Before.Select(item => item.Id).ToArray(),
reopenedOperation.Before.Count, "resolved exact test group", "integration-test", CancellationToken.None);
var resolvedGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Resolved, CancellationToken.None),
group => group.ReviewIds.Contains(review.Id));
Assert.Equal("resolved exact test group", resolvedGroup.Resolution);
Assert.NotNull(resolvedGroup.ResolvedAt);
var reopenedResolvedOperation = await store.ReopenReviewGroupAsync(resolvedGroup.ReviewIds, (int)resolvedGroup.Count, ReviewState.Resolved,
"reopened resolved test group", "integration-test", CancellationToken.None);
var reopenedResolvedReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id);
Assert.Equal(ReviewState.Open, reopenedResolvedReview.State);
Assert.Null(reopenedResolvedReview.Resolution);
Assert.Null(reopenedResolvedReview.ResolvedAt);
await store.UndoReviewOperationAsync(reopenedResolvedOperation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved);
await store.UndoReviewOperationAsync(resolvedOperation.Id, "integration-test", CancellationToken.None);
await store.UndoReviewOperationAsync(reopenedOperation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Deferred);
await store.UndoReviewOperationAsync(deferredOperation.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open);
var artifactReview = new ReviewItem(Guid.NewGuid(), "Unknown content", Severity.Notice, ReviewState.Open,
System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, artifact.RelativePath }), DateTimeOffset.UtcNow);
await store.AddReviewAsync(artifactReview, CancellationToken.None);
var artifactGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.ReviewIds.Contains(artifactReview.Id));
var supportOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count,
ReviewBulkAction.ClassifySupportContent, null, null, "integration-test", CancellationToken.None);
var classifiedSupport = Assert.IsType<ArtifactDetails>(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None));
Assert.Equal(ArtifactState.Ignored, classifiedSupport.Artifact.State);
Assert.Equal(MediaType.SupportFile, classifiedSupport.Artifact.MediaType);
Assert.Contains(classifiedSupport.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "support-content");
await store.UndoReviewOperationAsync(supportOperation.Id, "integration-test", CancellationToken.None);
var restoredSupportArtifact = Assert.IsType<ArtifactDetails>(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None));
Assert.Equal(ArtifactState.Present, restoredSupportArtifact.Artifact.State);
Assert.DoesNotContain(restoredSupportArtifact.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "support-content");
var platformOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count,
ReviewBulkAction.AcceptPlatform, "ps5", null, "integration-test", CancellationToken.None);
Assert.Equal("ps5", (await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None))!.Artifact.Platform);
await store.UndoReviewOperationAsync(platformOperation.Id, "integration-test", CancellationToken.None);
var restoredPlatformArtifact = Assert.IsType<ArtifactDetails>(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None));
Assert.Equal("nes", restoredPlatformArtifact.Artifact.Platform);
Assert.DoesNotContain(restoredPlatformArtifact.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "platform:ps5");
var exclusionOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count,
ReviewBulkAction.ExcludeFolderPattern, null, artifactGroup.PathPattern, "integration-test", CancellationToken.None);
Assert.Contains("roms/nes/**", (await store.GetLibraryAsync(library.Id, CancellationToken.None))!.Exclusions!);
await store.UndoReviewOperationAsync(exclusionOperation.Id, "integration-test", CancellationToken.None);
Assert.Empty((await store.GetLibraryAsync(library.Id, CancellationToken.None))!.Exclusions ?? []);
var resolution = await store.ResolveReviewAsync(review.Id, "accepted", "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved);
await store.UndoResolutionAsync(resolution.Id, "integration-test", CancellationToken.None);
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open);
var staleUndoReview = new ReviewItem(Guid.NewGuid(), "Synthetic stale undo", Severity.Notice, ReviewState.Open,
"{}", DateTimeOffset.UtcNow);
await store.AddReviewAsync(staleUndoReview, CancellationToken.None);
var staleUndoGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.ReviewIds.Contains(staleUndoReview.Id));
var staleUndoOperation = await store.DeferReviewGroupAsync(staleUndoGroup.ReviewIds, (int)staleUndoGroup.Count,
"deferred before a later action", "integration-test", CancellationToken.None);
await store.SetReviewStateAsync(staleUndoReview.Id, ReviewState.Open, "integration-test", CancellationToken.None);
await Assert.ThrowsAsync<ConcurrencyException>(() => store.UndoReviewOperationAsync(staleUndoOperation.Id,
"integration-test", CancellationToken.None));
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == staleUndoReview.Id && item.State == ReviewState.Open);
var mixedReason = $"Mixed resolution {Guid.NewGuid():N}";
await store.AddReviewAsync(new ReviewItem(Guid.NewGuid(), mixedReason, Severity.Notice, ReviewState.Resolved,
"{}", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "one recorded outcome"), CancellationToken.None);
await store.AddReviewAsync(new ReviewItem(Guid.NewGuid(), mixedReason, Severity.Notice, ReviewState.Resolved,
"{}", DateTimeOffset.UtcNow), CancellationToken.None);
var mixedGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Resolved, CancellationToken.None),
group => group.Reason == mixedReason);
Assert.Null(mixedGroup.Resolution);
Assert.Null(mixedGroup.ResolvedAt);
var catalogSource = new CatalogSource(Guid.NewGuid(), "Synthetic CRC catalog", "logiqx", "1",
null, "Ready", DateTimeOffset.UtcNow, 1);
var catalogEntry = new CatalogEntry(Guid.NewGuid(), catalogSource.Id, "Synthetic", artifact.RelativePath,
artifact.Size, null, null, content.Crc32, "synthetic-crc");
await store.UpsertCatalogSourceAsync(catalogSource, CancellationToken.None);
await store.UpsertCatalogEntriesAsync([catalogEntry], CancellationToken.None);
Assert.Equal(1, await store.MatchCatalogAsync(catalogSource.Id, CancellationToken.None));
var catalogReviewGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None),
group => group.Reason == "Catalog ambiguity" && group.ReviewIds.Count == 1);
await store.DeferReviewGroupAsync(catalogReviewGroup.ReviewIds, (int)catalogReviewGroup.Count,
"deferred catalog ambiguity", "integration-test", CancellationToken.None);
Assert.Equal(1, await store.MatchCatalogAsync(catalogSource.Id, CancellationToken.None));
var deferredCatalogReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None),
item => item.Reason == "Catalog ambiguity" && item.State == ReviewState.Deferred);
Assert.Equal("deferred catalog ambiguity", deferredCatalogReview.Resolution);
await using var restartedDataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var restartedStore = new PostgresStore(restartedDataSource);
await restartedStore.InitializeAsync(CancellationToken.None);
Assert.Contains(await restartedStore.ListLibrariesAsync(CancellationToken.None), item => item.Id == library.Id);
Assert.Contains(await restartedStore.ListGamesAsync(CancellationToken.None), item => item.Id == updated.Id && item.Title == updated.Title);
Assert.Equal(2, (await restartedStore.ListClaimsAsync("Game", game.Id, CancellationToken.None)).Count);
Assert.Equal(100, (await restartedStore.GetOperationalSettingsAsync(CancellationToken.None)).DefaultPageSize);
Assert.True((await restartedStore.GetGameUserStateAsync(game.Id, CancellationToken.None)).Favorite);
Assert.Contains(await restartedStore.ListCollectionsAsync(CancellationToken.None), item => item.Id == collection.Id && item.GameCount == 1);
Assert.Contains(await restartedStore.ListPlatformDefinitionsAsync(CancellationToken.None), item => item.Id == customPlatform.Id && !item.Enabled);
var restartedDataItem = Assert.Single(await restartedStore.ListGameDataAsync(game.Id, CancellationToken.None));
Assert.Equal(dataItem.Entry.Version, restartedDataItem.Entry.Version);
var deletedDataRevisions = await restartedStore.DeleteGameDataAsync(game.Id, dataEntryId,
restartedDataItem.Entry.Version, "integration-test", CancellationToken.None);
Assert.Equal(3, deletedDataRevisions.Count);
Assert.Empty(await restartedStore.ListGameDataAsync(game.Id, CancellationToken.None));
await using (var scale = dataSource.CreateCommand("""
INSERT INTO artifacts(id,library_id,relative_path,size,data,updated_at)
SELECT gen_random_uuid(), $1,
CASE WHEN value % 1000 = 0 THEN 'roms/nes/needle-' ELSE 'roms/nes/synthetic-' END || lpad(value::text, 6, '0') || '.nes',
32768, '{}'::jsonb, now()
FROM generate_series(1,250000) AS value
"""))
{
scale.CommandTimeout = 120;
scale.Parameters.AddWithValue(library.Id);
Assert.Equal(250_000, await scale.ExecuteNonQueryAsync());
}
await using (var analyze = dataSource.CreateCommand("EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM artifacts WHERE relative_path ILIKE '%needle%' ORDER BY relative_path LIMIT 50"))
await using (var reader = await ExecuteScaleQueryAsync(analyze))
{
var plan = new List<string>();
while (await reader.ReadAsync()) plan.Add(reader.GetString(0));
Assert.Contains(plan, line => line.Contains("ix_artifacts_path_trgm", StringComparison.Ordinal));
Assert.Contains(plan, line => line.Contains("Execution Time", StringComparison.Ordinal));
}
}
private static Task<NpgsqlDataReader> ExecuteScaleQueryAsync(NpgsqlCommand command)
{
command.CommandTimeout = 120;
return command.ExecuteReaderAsync();
}
}
@@ -0,0 +1,40 @@
using System.Net;
using System.Text;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class RawgGameDiscoveryServiceTests
{
[Fact]
public async Task MissingKeyReportsAnHonestDisabledCapabilityWithoutNetworkAccess()
{
using var client = new HttpClient(new StubHandler(_ => throw new InvalidOperationException("Network should not be used.")));
var response = await new RawgGameDiscoveryService(client, null).SearchAsync("Metroid", CancellationToken.None);
Assert.False(response.Configured);
Assert.Empty(response.Results);
Assert.Contains("RAWG_API_KEY", response.Message);
}
[Fact]
public async Task ConfiguredSearchNormalizesKnownPlatformsAndRetainsProviderIdentity()
{
const string json = """{"results":[{"id":42,"name":"Synthetic Quest","released":"2027-01-02","background_image":"https://images.example/42.jpg","slug":"synthetic-quest","platforms":[{"platform":{"slug":"playstation5"}}]}]}""";
using var client = new HttpClient(new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
}));
var response = await new RawgGameDiscoveryService(client, "synthetic-key").SearchAsync("Synthetic", CancellationToken.None);
var item = Assert.Single(response.Results);
Assert.True(response.Configured);
Assert.Equal("RAWG", item.Provider);
Assert.Equal("42", item.ExternalId);
Assert.Equal("ps5", item.Platform);
}
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,73 @@
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class ReadOnlyLibraryFileSystemTests : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), $"ludarium-exclusions-{Guid.NewGuid():N}");
[Fact]
public async Task EnumerationAppliesBoundedFolderAndFileGlobExclusions()
{
Directory.CreateDirectory(Path.Combine(root, "roms", "nes"));
Directory.CreateDirectory(Path.Combine(root, "tools", "emulator", "assets"));
await File.WriteAllBytesAsync(Path.Combine(root, "roms", "nes", "game.nes"), [1]);
await File.WriteAllBytesAsync(Path.Combine(root, "roms", "nes", "readme.txt"), [2]);
await File.WriteAllBytesAsync(Path.Combine(root, "tools", "emulator", "emu.exe"), [3]);
await File.WriteAllBytesAsync(Path.Combine(root, "tools", "emulator", "assets", "icon.png"), [4]);
var library = LibraryRoot.Create("Synthetic", root, LibraryKind.Mixed) with
{
Exclusions = ["tools/**", "roms/*/*.txt"]
};
var observed = new List<string>();
await foreach (var file in new ReadOnlyLibraryFileSystem().EnumerateAsync(library, CancellationToken.None))
observed.Add(file.RelativePath);
Assert.Equal(["roms/nes/game.nes"], observed);
}
[Fact]
public void VerificationReportsWhetherTheMountedSourceContainsEntries()
{
Directory.CreateDirectory(root);
var fileSystem = new ReadOnlyLibraryFileSystem();
Assert.False(fileSystem.Verify(root).HasEntries);
Directory.CreateDirectory(Path.Combine(root, "collection"));
Assert.True(fileSystem.Verify(root).HasEntries);
}
[Fact]
public void ResolutionRejectsAFileReachedThroughASymbolicLink()
{
Directory.CreateDirectory(root);
var outside = Path.Combine(Path.GetTempPath(), $"ludarium-outside-{Guid.NewGuid():N}.bin");
var link = Path.Combine(root, "linked.bin");
File.WriteAllText(outside, "not library content");
try
{
try { File.CreateSymbolicLink(link, outside); }
catch (Exception exception) when (exception is PlatformNotSupportedException or UnauthorizedAccessException or IOException)
{
return;
}
var library = LibraryRoot.Create("Synthetic", root, LibraryKind.Mixed);
Assert.Throws<InvalidOperationException>(() =>
new ReadOnlyLibraryFileSystem().ResolveContainedPath(library, "linked.bin"));
}
finally
{
if (File.Exists(link)) File.Delete(link);
if (File.Exists(outside)) File.Delete(outside);
}
}
public void Dispose()
{
if (Directory.Exists(root)) Directory.Delete(root, true);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,23 @@
using Ludarium.Application;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class RetailCoverProfileCoverageTests
{
[Fact]
public void EveryCanonicalBuiltInPlatformHasExactlyOneArtworkQualityProfile()
{
var canonical = PostgresStore.BuiltInPlatforms
.Select(platform => platform.Id)
.Order(StringComparer.Ordinal)
.ToArray();
var profiles = ArtworkQuality.RetailProfileRatios.Keys
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(68, canonical.Length);
Assert.Equal(canonical.Length, canonical.Distinct(StringComparer.Ordinal).Count());
Assert.Equal(canonical, profiles);
}
}
@@ -0,0 +1,58 @@
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Ludarium.IntegrationTests;
/// <summary>
/// What resolving a review leaves behind in the record.
/// </summary>
/// <remarks>
/// Ignoring non-game content wrote the state's name into the record, while the record stores the
/// enum's ordinal. Every row it touched became undeserialisable, and the review list reads them all,
/// so a single ignored file was enough to fail the whole Attention workspace from then on.
/// </remarks>
public sealed class ReviewResolutionTests
{
[Fact]
[Trait("Category", "Container")]
public async Task AResolvedReviewCanStillBeRead()
{
if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal))
return;
await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm")
.WithDatabase("ludarium_review_test")
.WithUsername("ludarium")
.WithPassword("synthetic-test-password")
.Build();
await postgres.StartAsync();
await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString());
var store = new PostgresStore(dataSource);
await store.InitializeAsync(CancellationToken.None);
var library = LibraryRoot.Create("Review fixture",
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-review-library")), LibraryKind.Rom) with
{ IsReadOnly = true, IsAvailable = true };
await store.UpsertLibraryAsync(library, CancellationToken.None);
var artifact = new Artifact(Guid.NewGuid(), library.Id, "roms/nes/readme.txt", 12,
DateTimeOffset.UtcNow.AddDays(-1), ArtifactState.Present, MediaType.Unknown, Confidence.Low, null,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1), null, null);
await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None);
await store.AddReviewAsync(new(Guid.NewGuid(), "Unknown content", Severity.Warning, ReviewState.Open,
$"{{\"artifactId\":\"{artifact.Id}\"}}", DateTimeOffset.UtcNow), CancellationToken.None);
await store.MarkArtifactIgnoredAsync(artifact.Id, artifact.Version + 1,
new("content.role", "operator", "review", "1", Confidence.Deterministic, DateTimeOffset.UtcNow),
CancellationToken.None);
// Reading the list is the part that used to throw, for every review the catalog holds.
var reviews = await store.ListReviewsAsync(CancellationToken.None);
var resolved = Assert.Single(reviews);
Assert.Equal(ReviewState.Resolved, resolved.State);
Assert.Equal("Ignored non-game content", resolved.Resolution);
}
}
@@ -0,0 +1,339 @@
using System.Security.Cryptography;
using System.IO.Compression;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class SafeScanTests
{
[Fact]
public async Task CueWithExactEcmSidecarIsReviewableButNotReportedMissing()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllTextAsync(Path.Combine(path, "disc.cue"), "FILE \"disc.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n");
await File.WriteAllBytesAsync(Path.Combine(path, "disc.bin.ecm"), "ECM\0synthetic-fixture"u8.ToArray());
var before = Manifest(path);
var store = new MemoryStore();
var root = LibraryRoot.Create("PSX", path, LibraryKind.DiscImage);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "ecm-sidecar", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var bundle = Assert.Single(await store.ListBundlesAsync(CancellationToken.None));
Assert.Equal(BundleState.CompleteWithWarnings, bundle.State);
Assert.Contains(bundle.Members, member => member.Role == "encoded track");
Assert.DoesNotContain(bundle.Findings, finding => finding.Contains("not found", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(await store.ListReviewsAsync(CancellationToken.None), item => item.Reason == "Incomplete bundle");
Assert.Equal(before, Manifest(path));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task DormantLibraryCannotBeScanned()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "game.nes"), [0x4e, 0x45, 0x53, 0x1a]);
var store = new MemoryStore();
var root = LibraryRoot.Create("Dormant", path, LibraryKind.Rom) with { Enabled = false };
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "dormant", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var completed = await store.GetScanAsync(scan.Id, CancellationToken.None);
Assert.Equal(ScanState.Failed, completed?.State);
Assert.Contains("dormant", completed?.Error, StringComparison.OrdinalIgnoreCase);
Assert.Empty((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task DeepScanPreservesSourceAndBuildsReviewableWindowsBundle()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "setup.exe"), "MZ-invalid-static-fixture"u8.ToArray());
await File.WriteAllBytesAsync(Path.Combine(path, "setup-1.bin"), "payload-one"u8.ToArray());
await File.WriteAllBytesAsync(Path.Combine(path, "setup-3.bin"), "payload-three"u8.ToArray());
await File.WriteAllBytesAsync(Path.Combine(path, "sample.nes"), [0x4e, 0x45, 0x53, 0x1a, 0, 0, 0, 0]);
Directory.CreateDirectory(Path.Combine(path, "roms", "Tools", "Switch"));
await File.WriteAllTextAsync(Path.Combine(path, "roms", "Tools", "Switch", "prod.keys"), "synthetic-key-list");
var before = Manifest(path);
var store = new MemoryStore(); var root = LibraryRoot.Create("Synthetic", path, LibraryKind.Mixed, hashPolicy: HashPolicy.CatalogCompatible);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "safe-scan", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
Assert.Equal(before, Manifest(path));
Assert.Equal(ScanState.Completed, (await store.GetScanAsync(scan.Id, CancellationToken.None))?.State);
var artifacts = await store.SearchArtifactsAsync(null, 1, 50, CancellationToken.None);
Assert.Equal(5, artifacts.Total);
Assert.Contains(artifacts.Items, item => item.State == ArtifactState.Ignored && item.MediaType == MediaType.SupportFile);
var bundle = Assert.Single(await store.ListBundlesAsync(CancellationToken.None));
Assert.Equal(BundleKind.WindowsInstaller, bundle.Kind); Assert.Equal(BundleState.Incomplete, bundle.State);
Assert.Contains(bundle.Findings, x => x.Contains("payload 2", StringComparison.Ordinal));
Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), x => x.Reason == "Incomplete bundle");
Assert.Equal(["ArtworkEnrichment", "MetadataEnrichment"], (await store.ListJobsAsync(CancellationToken.None)).Select(job => job.Kind).Order().ToArray());
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task VerifiedRenameRetainsArtifactIdentityAndUnavailableRootDoesNotRemoveIt()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "before.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]);
var store = new MemoryStore(); var root = LibraryRoot.Create("Moves", path, LibraryKind.Rom); await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var first = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "first", CancellationToken.None); await coordinator.ExecuteAsync(first.Id, CancellationToken.None);
var original = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
File.Move(Path.Combine(path, "before.nes"), Path.Combine(path, "after.nes"));
var second = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "second", CancellationToken.None); await coordinator.ExecuteAsync(second.Id, CancellationToken.None);
var moved = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
Assert.Equal(original.Id, moved.Id); Assert.Equal("after.nes", moved.RelativePath); Assert.Equal(ArtifactState.Present, moved.State);
Assert.Equal(1, (await store.GetScanAsync(second.Id, CancellationToken.None))?.MovedItems);
Directory.Move(path, path + "-offline");
var failed = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "offline", CancellationToken.None); await coordinator.ExecuteAsync(failed.Id, CancellationToken.None);
Assert.Equal(ArtifactState.Present, Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items).State);
Directory.Move(path + "-offline", path);
}
finally { if (Directory.Exists(path)) Directory.Delete(path, true); if (Directory.Exists(path + "-offline")) Directory.Delete(path + "-offline", true); }
}
[Fact]
public async Task EmptyMountedSourceDoesNotEraseAnExistingCatalog()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "preserved.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]);
var store = new MemoryStore();
var root = LibraryRoot.Create("Protected", path, LibraryKind.Rom);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var initial = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "initial", CancellationToken.None);
await coordinator.ExecuteAsync(initial.Id, CancellationToken.None);
File.Delete(Path.Combine(path, "preserved.nes"));
var empty = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "empty", CancellationToken.None);
await coordinator.ExecuteAsync(empty.Id, CancellationToken.None);
var result = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
Assert.Equal(ArtifactState.Present, result.State);
Assert.Equal(ScanState.Failed, (await store.GetScanAsync(empty.Id, CancellationToken.None))?.State);
Assert.Contains("source is empty", (await store.GetScanAsync(empty.Id, CancellationToken.None))?.Error, StringComparison.OrdinalIgnoreCase);
Assert.False((await store.GetLibraryAsync(root.Id, CancellationToken.None))?.HasEntries);
}
finally { if (Directory.Exists(path)) Directory.Delete(path, true); }
}
[Fact]
public async Task IntegrityScanHashesOnDemandLibrariesWithoutMutatingTheSource()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
await File.WriteAllBytesAsync(Path.Combine(path, "identity.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]);
var before = Manifest(path);
var store = new MemoryStore(); var root = LibraryRoot.Create("On demand", path, LibraryKind.Rom, hashPolicy: HashPolicy.OnDemand);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Integrity, "integrity", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var artifact = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
Assert.NotNull(artifact.Sha256);
Assert.Equal(before, Manifest(path));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, true); }
}
[Fact]
public async Task DeepScanRecordsZipMembersWithoutChangingTheArchive()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
var archivePath = Path.Combine(path, "collection.zip");
await using (var output = File.Create(archivePath))
using (var archive = new ZipArchive(output, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("game/demo.nes");
await using var member = entry.Open();
await member.WriteAsync(new byte[] { 0x4e, 0x45, 0x53, 0x1a });
}
var before = Manifest(path);
var store = new MemoryStore();
var root = LibraryRoot.Create("Archives", path, LibraryKind.Mixed, inspectArchives: true);
await store.UpsertLibraryAsync(root, CancellationToken.None);
var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem());
var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "zip", CancellationToken.None);
await coordinator.ExecuteAsync(scan.Id, CancellationToken.None);
var artifact = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items);
var memberRecord = Assert.Single(await store.ListArchiveMembersAsync(artifact.Id, CancellationToken.None));
Assert.Equal("game/demo.nes", memberRecord.Path);
Assert.Equal(before, Manifest(path));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
[Fact]
public async Task InventoryExportsAreVersionedEscapedAndCancellationSafe()
{
var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
try
{
var store = new MemoryStore();
var artifact = new Artifact(Guid.NewGuid(), Guid.NewGuid(), "quoted,\nname.nes", 4, DateTimeOffset.UtcNow,
ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, Guid.NewGuid(), DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow, "iNES", "nes", Sha256: new string('a', 64));
await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None);
var service = new InventoryExportService(store, path);
foreach (var format in new[] { "json", "csv", "sha256" })
{
var result = await service.CreateAsync(format, CancellationToken.None);
var content = await File.ReadAllTextAsync(Path.Combine(path, result.FileName));
Assert.Contains(ReleaseIdentity.Version, content, StringComparison.Ordinal);
Assert.Contains(format == "json" ? "schemaVersion" : "ludarium-schema-version", content, StringComparison.Ordinal);
if (format == "sha256") Assert.Contains("quoted,\\nname.nes", content, StringComparison.Ordinal);
Assert.Equal(1, result.Count);
}
var cancelledRoot = Path.Combine(path, "cancelled");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new InventoryExportService(store, cancelledRoot).CreateAsync("json", cancellation.Token));
Assert.Empty(Directory.GetFiles(cancelledRoot));
}
finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
}
private static string Manifest(string root) => string.Join('|', Directory.GetFiles(root).Order().Select(x => Path.GetFileName(x) + ":" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x)))));
private sealed class MemoryStore : ILudariumStore
{
private readonly Dictionary<Guid, LibraryRoot> libraries = []; private readonly Dictionary<Guid, ScanRun> scans = []; private readonly Dictionary<Guid, Artifact> artifacts = []; private readonly Dictionary<Guid, Bundle> bundles = []; private readonly Dictionary<Guid, ReviewItem> reviews = []; private readonly Dictionary<Guid, IReadOnlyList<ArchiveMember>> archiveMembers = []; private readonly List<Finding> findings = []; private readonly List<BackgroundJob> jobs = [];
private OperationalSettings settings = OperationalSettings.Defaults;
public Task InitializeAsync(CancellationToken c) => Task.CompletedTask;
public Task<int> GetSchemaVersionAsync(CancellationToken c) => Task.FromResult(PostgresStore.CurrentSchemaVersion);
public Task<IReadOnlyList<LibraryRoot>> ListLibrariesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<LibraryRoot>>(libraries.Values.ToArray());
public Task<LibraryRoot?> GetLibraryAsync(Guid id, CancellationToken c) => Task.FromResult(libraries.GetValueOrDefault(id));
public Task<IReadOnlyList<LibraryHealthSummary>> ListLibraryHealthAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<LibraryHealthSummary>>([]);
public Task UpsertLibraryAsync(LibraryRoot r, CancellationToken c) { libraries[r.Id] = r; return Task.CompletedTask; }
public Task<ScanRun> CreateScanAsync(Guid libraryId, ScanMode mode, string? key, CancellationToken c) { var existing = scans.Values.FirstOrDefault(x => x.Checkpoint == "key:" + key); if (existing is not null) return Task.FromResult(existing); var s = new ScanRun(Guid.NewGuid(), libraryId, mode, ScanState.Queued, "Queued", 0, null, 0, null, "key:" + key, false, DateTimeOffset.UtcNow); scans[s.Id] = s; return Task.FromResult(s); }
public Task<IReadOnlyList<ScanRun>> ListScansAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ScanRun>>(scans.Values.ToArray());
public Task<ScanRun?> GetScanAsync(Guid id, CancellationToken c) => Task.FromResult(scans.GetValueOrDefault(id));
public Task UpdateScanAsync(ScanRun s, CancellationToken c) { scans[s.Id] = s; return Task.CompletedTask; }
public Task UpsertArtifactAsync(Artifact a, ContentBlob? b, IReadOnlyList<Evidence> e, CancellationToken c) { artifacts[a.Id] = a; return Task.CompletedTask; }
public Task MarkArtifactIgnoredAsync(Guid id, long version, Evidence evidence, CancellationToken c) { artifacts[id] = artifacts[id] with { State = ArtifactState.Ignored, MediaType = MediaType.SupportFile, Platform = null, Version = version }; return Task.CompletedTask; }
public Task<ArtifactOverride?> GetArtifactOverrideAsync(Guid artifactId, CancellationToken c) => Task.FromResult<ArtifactOverride?>(null);
public Task<Artifact?> FindByPathAsync(Guid l, string p, CancellationToken c) => Task.FromResult(artifacts.Values.FirstOrDefault(x => x.LibraryId == l && x.RelativePath.Equals(p, StringComparison.OrdinalIgnoreCase)));
public Task<Artifact?> FindByHashAsync(Guid l, long z, string h, CancellationToken c) => Task.FromResult(artifacts.Values.FirstOrDefault(x => x.LibraryId == l && x.Size == z && x.Sha256 == h));
public Task<long> MarkMissingExceptAsync(Guid l, IReadOnlySet<string> paths, DateTimeOffset at, CancellationToken c) { var missing = artifacts.Values.Where(x => x.LibraryId == l && x.State == ArtifactState.Present && !paths.Contains(x.RelativePath)).ToArray(); foreach (var a in missing) artifacts[a.Id] = a with { State = ArtifactState.Missing }; return Task.FromResult((long)missing.Length); }
public Task<Page<Artifact>> SearchArtifactsAsync(string? q, int p, int z, CancellationToken c) { var x = artifacts.Values.Where(a => q is null || a.RelativePath.Contains(q, StringComparison.OrdinalIgnoreCase)).ToArray(); return Task.FromResult(new Page<Artifact>(x, p, z, x.Length)); }
public Task<ArtifactDetails?> GetArtifactDetailsAsync(Guid id, CancellationToken c) => Task.FromResult<ArtifactDetails?>(null);
public Task<DashboardSummary> GetDashboardSummaryAsync(CancellationToken c) => Task.FromResult(new DashboardSummary(libraries.Count, artifacts.Count, artifacts.Count, 0, 0, bundles.Count, artifacts.Values.Sum(x => x.Size), artifacts.Values.LongCount(x => x.Sha256 is not null), artifacts.Values.LongCount(x => x.MediaType != MediaType.Unknown), artifacts.Values.LongCount(x => x.MediaType == MediaType.Unknown), artifacts.Values.LongCount(x => x.State == ArtifactState.Missing), 0, 0, reviews.Values.LongCount(x => x.State != ReviewState.Resolved), findings.LongCount(x => x.Severity == Severity.Critical), scans.Values.LongCount(x => x.State is not (ScanState.Completed or ScanState.Failed or ScanState.Cancelled)), 0));
public Task<StorageSummary> GetStorageSummaryAsync(CancellationToken c)
{
var summaries = libraries.Values.Select(library =>
{
var stored = artifacts.Values.Where(item => item.LibraryId == library.Id).ToArray();
return new LibraryStorageSummary(library.Id, library.Name, stored.LongLength,
stored.Where(item => item.State == ArtifactState.Present).Sum(item => item.Size),
stored.LongCount(item => item.State == ArtifactState.Missing));
}).ToArray();
return Task.FromResult(new StorageSummary(artifacts.Values.Sum(x => x.Size), 0, [], summaries));
}
public Task<HealthSummary> GetHealthSummaryAsync(CancellationToken c) => Task.FromResult(new HealthSummary(0, 0, 0, 0, 0, 0, findings));
public Task<IReadOnlyList<Artifact>> GetDuplicatesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Artifact>>(artifacts.Values.GroupBy(x => x.Sha256).Where(x => x.Key is not null && x.Count() > 1).SelectMany(x => x).ToArray());
public Task ReplaceArchiveMembersAsync(Guid artifactId, IReadOnlyList<ArchiveMember> members, CancellationToken c) { archiveMembers[artifactId] = members; return Task.CompletedTask; }
public Task<IReadOnlyList<ArchiveMember>> ListArchiveMembersAsync(Guid artifactId, CancellationToken c) => Task.FromResult(archiveMembers.GetValueOrDefault(artifactId) ?? []);
public Task AddBundleAsync(Bundle b, CancellationToken c) { bundles[b.Id] = b; return Task.CompletedTask; }
public Task<IReadOnlyList<Bundle>> ListBundlesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Bundle>>(bundles.Values.ToArray());
public Task AddReviewAsync(ReviewItem r, CancellationToken c) { reviews.TryAdd(r.Id, r); return Task.CompletedTask; }
public Task<IReadOnlyList<ReviewItem>> ListReviewsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ReviewItem>>(reviews.Values.ToArray());
public Task<Page<ReviewItem>> SearchReviewsAsync(ReviewState? state, string? reason, int p, int z, CancellationToken c) { var x = reviews.Values.Where(r => (state is null || r.State == state) && (reason is null || r.Reason == reason)).ToArray(); return Task.FromResult(new Page<ReviewItem>(x, p, z, x.Length)); }
public Task<ReviewItem> SetReviewStateAsync(Guid id, ReviewState state, string actor, CancellationToken c) { reviews[id] = reviews[id] with { State = state }; return Task.FromResult(reviews[id]); }
public Task<ManualResolution> ResolveReviewAsync(Guid id, string r, string a, CancellationToken c) { var before = reviews[id]; reviews[id] = before with { State = ReviewState.Resolved, Resolution = r }; return Task.FromResult(new ManualResolution(Guid.NewGuid(), id, r, a, System.Text.Json.JsonSerializer.Serialize(before), System.Text.Json.JsonSerializer.Serialize(reviews[id]), DateTimeOffset.UtcNow)); }
public Task UndoResolutionAsync(Guid id, string actor, CancellationToken c) => Task.CompletedTask;
public Task AddClaimAsync(MetadataClaim claim, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<MetadataClaim>> ListClaimsAsync(string entityType, Guid entityId, CancellationToken c) => Task.FromResult<IReadOnlyList<MetadataClaim>>([]);
public Task<Game> CreateGameAsync(string title, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<GameDeletionResult> DeleteGameAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<Game> UpdateGameAsync(Guid id, string title, long version, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<Game?> GetGameAsync(Guid id, CancellationToken c) => Task.FromResult<Game?>(null);
public Task<IReadOnlyList<Game>> ListGamesAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Game>>([]);
public Task<Page<Game>> SearchGamesAsync(GameQuery query, CancellationToken c) =>
Task.FromResult(new Page<Game>([], query.Page, query.PageSize, 0));
public Task<IReadOnlyList<Release>> ListReleasesForGamesAsync(IReadOnlyList<Guid> gameIds, CancellationToken c) =>
Task.FromResult<IReadOnlyList<Release>>([]);
public Task<Page<WishlistItem>> SearchWishlistAsync(string? q, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int page, int pageSize, CancellationToken c) => Task.FromResult(new Page<WishlistItem>([], page, pageSize, 0));
public Task<WishlistSummary> GetWishlistSummaryAsync(CancellationToken c) => Task.FromResult(new WishlistSummary(0, 0, 0, 0, 0));
public Task<WishlistItem> CreateWishlistItemAsync(WishlistInput input, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<WishlistItem> UpdateWishlistItemAsync(Guid id, WishlistInput input, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task DeleteWishlistItemAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => Task.CompletedTask;
public Task<int> ReconcileWishlistAsync(string actor, CancellationToken c) => Task.FromResult(0);
public Task<IReadOnlyList<ReviewGroup>> ListReviewGroupsAsync(ReviewState state, CancellationToken c) => Task.FromResult<IReadOnlyList<ReviewGroup>>([]);
public Task<ReviewOperation> ResolveReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string resolution, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<ReviewOperation> ApplyReviewGroupActionAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewBulkAction action, string? platform, string? pathPattern, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<ReviewOperation> DeferReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string reason, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<ReviewOperation> ReopenReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewState sourceState, string reason, string actor, CancellationToken c) => throw new NotSupportedException();
public Task UndoReviewOperationAsync(Guid id, string actor, CancellationToken c) => Task.CompletedTask;
public Task<int> SynchronizeDiscoveredGamesAsync(Guid libraryId, CancellationToken c) => Task.FromResult(0);
public Task UpsertGameArtworkAsync(GameArtwork artwork, CancellationToken c) => Task.CompletedTask;
public Task<GameArtwork?> GetSelectedGameArtworkAsync(Guid gameId, CancellationToken c, string? platform = null) => Task.FromResult<GameArtwork?>(null);
public Task<IReadOnlyList<GameArtwork>> ListGameArtworkAsync(Guid gameId, CancellationToken c) => Task.FromResult<IReadOnlyList<GameArtwork>>([]);
public Task SelectGameArtworkAsync(Guid gameId, Guid artworkId, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<GameArtwork>> ListArtworkNeedingReviewAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<GameArtwork>>([]);
public Task<IReadOnlyList<ArtworkReviewItem>> ListArtworkReviewItemsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ArtworkReviewItem>>([]);
public Task<BackgroundJob> EnqueueJobAsync(string kind, string payload, string? key, CancellationToken c)
{
var now = DateTimeOffset.UtcNow;
var job = new BackgroundJob(Guid.NewGuid(), kind, JobState.Queued, payload, 0, null, 0, 3, now, now, now);
jobs.Add(job);
return Task.FromResult(job);
}
public Task<BackgroundJob?> LeaseJobAsync(string worker, TimeSpan lease, CancellationToken c) => Task.FromResult<BackgroundJob?>(null);
public Task UpdateJobAsync(BackgroundJob job, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<BackgroundJob>> ListJobsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<BackgroundJob>>(jobs);
public Task<BackgroundJob?> GetJobAsync(Guid id, CancellationToken c) => Task.FromResult<BackgroundJob?>(null);
public Task RequestJobCancellationAsync(Guid id, CancellationToken c) => Task.CompletedTask;
public Task<BackgroundJob> RetryJobAsync(Guid id, CancellationToken c) => throw new NotSupportedException();
public Task UpsertProviderSnapshotAsync(ProviderSnapshot snapshot, CancellationToken c) => Task.CompletedTask;
public Task<IReadOnlyList<ProviderSnapshot>> ListProviderSnapshotsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<ProviderSnapshot>>([]);
public Task<IReadOnlyList<PlatformSummary>> ListPlatformsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<PlatformSummary>>([]);
public Task<Release> CreateReleaseAsync(Guid gameId, string title, string? platform, string? region, string? revision, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<Release> UpdateReleaseAsync(Guid id, string title, string? platform, string? region, string? revision, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task DeleteReleaseAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException();
public Task<IReadOnlyList<Release>> ListReleasesAsync(Guid? gameId, CancellationToken c) => Task.FromResult<IReadOnlyList<Release>>([]);
public Task<ArtifactDetails?> GetGameArtworkAsync(Guid gameId, CancellationToken c) => Task.FromResult<ArtifactDetails?>(null);
public Task<IReadOnlyList<Finding>> ListFindingsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<Finding>>(findings); public Task AddFindingAsync(Finding f, CancellationToken c) { findings.Add(f); return Task.CompletedTask; }
public Task<IntegritySnapshot> CreateSnapshotAsync(string n, CancellationToken c) => throw new NotSupportedException(); public Task<IReadOnlyList<IntegritySnapshot>> ListSnapshotsAsync(CancellationToken c) => Task.FromResult<IReadOnlyList<IntegritySnapshot>>([]); public Task<IntegritySnapshot?> GetSnapshotAsync(Guid id, CancellationToken c) => Task.FromResult<IntegritySnapshot?>(null);
public Task<OperationalSettings> GetOperationalSettingsAsync(CancellationToken c) => Task.FromResult(settings);
public Task<OperationalSettings> SaveOperationalSettingsAsync(OperationalSettings value, long expectedVersion, CancellationToken c) { settings = value with { Version = expectedVersion + 1 }; return Task.FromResult(settings); }
}
}
@@ -0,0 +1,57 @@
using System.IO.Compression;
using System.Reflection;
using Ludarium.Application;
using Ludarium.Domain;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class SupportBundleServiceTests
{
[Fact]
public async Task BundleRedactsPathsErrorsAndFindingDiagnosticsByConstruction()
{
const string secret = "super-secret-token";
var now = DateTimeOffset.UtcNow;
var libraryId = Guid.NewGuid();
var store = DispatchProxy.Create<ILudariumStore, SupportStoreProxy>();
var proxy = (SupportStoreProxy)(object)store;
proxy.Libraries = [new(libraryId, "Games", $"/mnt/{secret}", LibraryKind.Mixed, true, HashPolicy.Sha256, false, 1, true, true, true, true, now)];
proxy.Scans = [new(Guid.NewGuid(), libraryId, ScanMode.Deep, ScanState.Failed, "failed", 1, 1, 10, 10, null, false, now, now, now, $"Host=database;Password={secret}")];
proxy.Findings = [new(Guid.NewGuid(), "Integrity", Severity.Critical, $"API key {secret} found at /mnt/private/game.rom", null, null, now)];
var directory = Path.Combine(Path.GetTempPath(), "ludarium-support-tests", Guid.NewGuid().ToString("N"));
try
{
var result = await new SupportBundleService(store, directory).CreateAsync(CancellationToken.None);
using var archive = ZipFile.OpenRead(Path.Combine(directory, result.FileName));
var text = string.Join('\n', archive.Entries.Select(entry =>
{
using var reader = new StreamReader(entry.Open());
return reader.ReadToEnd();
}));
Assert.DoesNotContain(secret, text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("/mnt/private", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password=", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("[redacted]", text, StringComparison.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
public class SupportStoreProxy : DispatchProxy
{
public IReadOnlyList<LibraryRoot> Libraries { get; set; } = [];
public IReadOnlyList<ScanRun> Scans { get; set; } = [];
public IReadOnlyList<Finding> Findings { get; set; } = [];
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => targetMethod?.Name switch
{
nameof(ILudariumStore.ListLibrariesAsync) => Task.FromResult(Libraries),
nameof(ILudariumStore.ListScansAsync) => Task.FromResult(Scans),
nameof(ILudariumStore.ListFindingsAsync) => Task.FromResult(Findings),
_ => throw new NotSupportedException(targetMethod?.Name)
};
}
}
@@ -0,0 +1,54 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Infrastructure;
namespace Ludarium.IntegrationTests;
public sealed class SwitchRuntimeProvisionerTests
{
[Fact]
public async Task VerifiedPersonalKeysAreAtomicallyProvisionedOutsideTheSourceLibrary()
{
var root = Path.Combine(Path.GetTempPath(), $"ludarium-switch-{Guid.NewGuid():N}");
var vaultRoot = Path.Combine(root, "vault");
var runtimeRoot = Path.Combine(root, "eden-keys");
try
{
var vault = new FirmwareVaultFileStore(vaultRoot);
var provisioner = new SwitchRuntimeProvisioner(runtimeRoot);
var requirement = FirmwarePolicy.Get("switch", "prod-keys");
const string fixture = "synthetic-switch-provisioning-fixture\n";
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(fixture));
var asset = await vault.SaveAsync(requirement, Guid.NewGuid(), "prod.keys", input,
input.Length, CancellationToken.None);
await provisioner.ProvisionAsync(asset, vault, CancellationToken.None);
var target = Path.Combine(runtimeRoot, "prod.keys");
Assert.True(provisioner.Configured);
Assert.Equal(fixture, await File.ReadAllTextAsync(target));
if (!OperatingSystem.IsWindows())
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(target));
Assert.Empty(Directory.GetFiles(runtimeRoot, "*.uploading"));
provisioner.Remove(asset);
Assert.False(File.Exists(target));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[Fact]
public async Task DisabledProvisionerNeverCopiesKeys()
{
var provisioner = new SwitchRuntimeProvisioner(null);
var asset = new Ludarium.Domain.FirmwareAsset(Guid.NewGuid(), "switch", "prod-keys",
Ludarium.Domain.FirmwareAssetKind.KeySet, "prod.keys", 1, new string('0', 64), "missing", true,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
await provisioner.ProvisionAsync(asset, null!, CancellationToken.None);
Assert.False(provisioner.Configured);
}
}
@@ -0,0 +1,281 @@
{
"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"
}
},
"SSH.NET": {
"type": "Direct",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Testcontainers.PostgreSql": {
"type": "Direct",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==",
"dependencies": {
"Testcontainers": "4.13.0"
}
},
"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=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"Docker.DotNet.Enhanced": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3",
"Docker.DotNet.Enhanced.LegacyHttp": "4.3.3",
"Docker.DotNet.Enhanced.NPipe": "4.3.3",
"Docker.DotNet.Enhanced.NativeHttp": "4.3.3",
"Docker.DotNet.Enhanced.Unix": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Docker.DotNet.Enhanced.Handler.Abstractions": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Docker.DotNet.Enhanced.LegacyHttp": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.NativeHttp": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.NPipe": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.Unix": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.X509": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"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=="
},
"SharpZipLib": {
"type": "Transitive",
"resolved": "1.4.2",
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.11",
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
},
"Testcontainers": {
"type": "Transitive",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
},
"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, )"
}
}
}
}
}
+444
View File
@@ -0,0 +1,444 @@
using System.Text;
using System.Buffers.Binary;
using System.IO.Compression;
using Ludarium.Application;
using Ludarium.Domain;
using OpenMcdf;
namespace Ludarium.UnitTests;
public sealed class AnalysisTests
{
[Theory]
[InlineData("3DS/LIMBO/BLUEROMS.WS.html")]
[InlineData("3DS/World of Goo/download.htm")]
public void WebPagesAreSupportArtifactsRatherThanGames(string path)
{
var result = Assert.IsType<NonGamePath>(LibraryContentPolicy.IdentifyNonGamePath(path));
Assert.Equal("NonGameSupport", result.Category);
Assert.StartsWith("web-page:", result.Evidence);
}
[Theory]
[InlineData("roms/Tools/Switch/prod.keys")]
[InlineData("PC/.gameyfin/cache/cover.jpg")]
[InlineData("Game/runtime/libSceAudioOut.prx")]
[InlineData("roms/PS2/BIOS/scph39001.bin")]
[InlineData("roms/Switch/keys/prod.keys")]
[InlineData("PC/Game/manuals/manual.pdf")]
[InlineData("roms/N64/Game/readme.txt")]
public void NonGameDirectoriesAreDeterministicallyIdentified(string path)
{
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath(path));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/FMT-v23.23.2/FMT/FMT.exe"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steam.exe"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Game DLC Unlocker/tool.exe"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steamapps/common/tool.dll"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("roms/Tools/Emulators/retroarch.exe"));
}
[Theory]
[InlineData("Super Mario 64 (USA) (Rev 1).z64", "Super Mario 64")]
[InlineData("Asterix.and.Obelix.Mission.Babylon-DUPLEX", "Asterix and Obelix Mission Babylon")]
[InlineData("Fallout 4 [FitGirl Repack]", "Fallout 4")]
[InlineData("Metal Gear Solid (Disc 2)", "Metal Gear Solid")]
[InlineData("0002 - Need for Speed - Underground 2 (USA).zip", "Need for Speed - Underground 2")]
[InlineData("Animal Crossing- City Folk [RUUE01].wbfs", "Animal Crossing- City Folk")]
[InlineData("Pokemon Diamond USA NDS-LGC.nds", "Pokemon Diamond")]
[InlineData("Asterix and Obelix Mission Babylon PS5", "Asterix and Obelix Mission Babylon")]
[InlineData("Assassins's Creed 4 - Black Flag", "Assassin's Creed 4 - Black Flag")]
[InlineData("Call of Duty - Black Ops 6 PS5 iNTERNAL-PS5B", "Call of Duty - Black Ops 6")]
[InlineData("Crash Bandicoot - The Wrath of Cortex (USA) (v1", "Crash Bandicoot - The Wrath of Cortex")]
[InlineData("Crash Team Racing [U] [SCUS-94426]", "Crash Team Racing")]
[InlineData("Burnout Legends", "Burnout Legends")]
[InlineData("The Darkness", "The Darkness")]
public void CanonicalTitlesRemovePackagingNoise(string raw, string expected)
{
Assert.Equal(expected, LibraryContentPolicy.CanonicalGameTitle(raw));
}
[Fact]
public void RomHeaderEvidenceNeverContainsDatabaseInvalidControlCharacters()
{
var header = new byte[0xC0];
"TEST\0TITLE"u8.CopyTo(header.AsSpan(0xA0));
header[0xB2] = 0x96;
var result = ArtifactAnalysis.Classify("synthetic.gba", header);
Assert.DoesNotContain(result.Evidence, item => item.Value.Any(char.IsControl));
}
[Fact]
public async Task HashesAreStreamedAndMatchKnownVectors()
{
await using var stream = new MemoryStream("abc"u8.ToArray());
var result = await ArtifactAnalysis.HashAsync(stream, true, CancellationToken.None);
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", result.Sha256);
Assert.Equal("a9993e364706816aba3e25717850c26c9cd0d89d", result.Sha1);
Assert.Equal("c2412435", result.Crc32);
Assert.Equal(3, result.BytesRead);
}
[Theory]
[InlineData("game.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, MediaType.Rom, Confidence.Deterministic)]
[InlineData("setup.exe", new byte[] { 0x4d, 0x5a }, MediaType.WindowsPackage, Confidence.Deterministic)]
[InlineData("archive.zip", new byte[] { 0x50, 0x4b, 0x03, 0x04 }, MediaType.Archive, Confidence.Deterministic)]
[InlineData("mystery.bin", new byte[] { 0x00 }, MediaType.Unknown, Confidence.None)]
public void ClassificationUsesSignatureEvidence(string name, byte[] header, MediaType expected, Confidence confidence)
{
var result = ArtifactAnalysis.Classify(name, header);
Assert.Equal(expected, result.MediaType); Assert.Equal(confidence, result.Confidence);
Assert.Equal(expected != MediaType.Unknown, result.Supported);
}
[Fact]
public void CueParserHandlesQuotedAndUnquotedReferences()
{
var refs = ArtifactAnalysis.ParseCueReferences("FILE \"Disc 1.bin\" BINARY\n FILE Disc2.bin BINARY");
Assert.Equal(["Disc 1.bin", "Disc2.bin"], refs);
}
[Fact]
public void M3uParserPreservesOrderAndIgnoresComments()
{
Assert.Equal(["Disc 2.cue", "Disc 1.cue"], ArtifactAnalysis.ParseM3uReferences("#EXTM3U\nDisc 2.cue\nDisc 1.cue"));
}
[Theory]
[InlineData("../outside.bin", false)]
[InlineData("tracks/disc.bin", true)]
[InlineData("/absolute.bin", false)]
public void DescriptorReferencesCannotEscapeRoot(string path, bool safe) => Assert.Equal(safe, ArtifactAnalysis.IsSafeRelativeReference(path));
[Fact]
public void ScanStateMachineRejectsInvalidTransitions()
{
var run = new ScanRun(Guid.NewGuid(), Guid.NewGuid(), ScanMode.Quick, ScanState.Queued, "Queued", 0, null, 0, null, null, false, DateTimeOffset.UtcNow);
Assert.Equal(ScanState.Verifying, run.Transition(ScanState.Verifying, "Verify").State);
Assert.Throws<InvalidOperationException>(() => run.Transition(ScanState.Completed, "Complete"));
}
[Fact]
public void LibraryRequiresAbsolutePath() => Assert.Throws<ArgumentException>(() => LibraryRoot.Create("ROMs", "relative", LibraryKind.Rom));
[Fact]
public void PeInspectionReadsArchitectureWithoutExecutingImage()
{
using var stream = File.OpenRead(typeof(AnalysisTests).Assembly.Location);
var result = WindowsPackageAnalysis.InspectPe(stream);
Assert.True(result.IsManaged);
Assert.True(result.Kind is "library" or "executable");
Assert.NotEqual(Confidence.None, result.Confidence);
Assert.Contains("ProductVersion", result.VersionInfo.Keys);
Assert.Contains(result.Evidence, item => item.Kind == "pe.version.ProductVersion");
}
[Fact]
public void NumberedInstallerPayloadGapsAreDetected() =>
Assert.Equal([2], WindowsPackageAnalysis.MissingNumberedPayloads(["setup-1.bin", "setup-3.bin"]));
[Fact]
public void MsiSummaryInformationIsReadWithoutInvokingWindowsInstaller()
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.msi");
try
{
using (var root = RootStorage.Create(path))
using (var summary = root.CreateStream("\u0005SummaryInformation"))
summary.Write(CreateSummaryPropertySet((2, "Synthetic Installer"), (4, "Ludarium Tests"),
(7, "x64;1033"), (9, "{11111111-2222-3333-4444-555555555555}")));
var result = WindowsPackageAnalysis.InspectMsiSummary(path);
Assert.Equal("Synthetic Installer", result["Title"]);
Assert.Equal("Ludarium Tests", result["Author"]);
Assert.Equal("x64", result["Architecture"]);
Assert.Equal("1033", result["Language"]);
Assert.Equal("{11111111-2222-3333-4444-555555555555}", result["PackageCode"]);
}
finally { File.Delete(path); }
}
[Theory]
[InlineData("game.z64", new byte[] { 0x80, 0x37, 0x12, 0x40 }, "n64")]
[InlineData("game.v64", new byte[] { 0x37, 0x80, 0x40, 0x12 }, "n64")]
[InlineData("game.n64", new byte[] { 0x40, 0x12, 0x37, 0x80 }, "n64")]
[InlineData("game.gba", new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 }, "gba")]
public void RomHeadersOverrideExtensionHints(string name, byte[] header, string platform)
{
var result = ArtifactAnalysis.Classify(name, header);
Assert.Equal(MediaType.Rom, result.MediaType);
Assert.Equal(platform, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
}
[Theory]
[InlineData("game.cso", new byte[] { 0x43, 0x49, 0x53, 0x4f }, "psp", "PSP CSO")]
[InlineData("game.nsp", new byte[] { 0x50, 0x46, 0x53, 0x30 }, "switch", "Switch PFS0")]
public void ModernContainerSignaturesProvideDeterministicPlatformEvidence(string name, byte[] header,
string platform, string signature)
{
var result = ArtifactAnalysis.Classify(name, header);
Assert.Equal(MediaType.DiscImage, result.MediaType);
Assert.Equal(platform, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "signature" && item.Value == signature);
}
[Fact]
public void SwitchXciHeaderIsDetectedAtItsSpecifiedOffset()
{
var header = new byte[0x104];
"HEAD"u8.CopyTo(header.AsSpan(0x100));
var result = ArtifactAnalysis.Classify("game.xci", header);
Assert.Equal("switch", result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Value == "Switch XCI");
var misplaced = ArtifactAnalysis.Classify("roms/PS2/game.xci", header, "ps2");
Assert.Equal("switch", misplaced.Platform);
Assert.Contains(misplaced.Evidence, item => item.Kind == "platform.conflict" &&
item.Value == "directory:ps2;signature:switch");
}
[Theory]
[InlineData("psx")]
[InlineData("ps2")]
[InlineData("psp")]
public async Task Iso9660ContentsIdentifyPlayStationFamiliesWithoutDirectoryHints(string expectedPlatform)
{
await using var iso = new MemoryStream(CreatePlayStationIso(expectedPlatform));
var result = await DiscImageAnalysis.InspectIsoAsync(iso, CancellationToken.None);
Assert.Equal(expectedPlatform, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "ISO9660-CD001");
Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value.StartsWith(expectedPlatform + ":", StringComparison.Ordinal));
}
[Fact]
public async Task TruncatedOrGenericIsoNeverInventsAPlatform()
{
await using var truncated = new MemoryStream(new byte[1024]);
Assert.Null((await DiscImageAnalysis.InspectIsoAsync(truncated, CancellationToken.None)).Platform);
await using var generic = new MemoryStream(CreatePlayStationIso(null));
var result = await DiscImageAnalysis.InspectIsoAsync(generic, CancellationToken.None);
Assert.Null(result.Platform);
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature");
}
[Theory]
[InlineData(false, "psp")]
[InlineData(true, "psx")]
public async Task PbpPayloadDeterministicallyDistinguishesPspHomebrewFromPsx(bool psx, string expected)
{
var bytes = new byte[96];
"\0PBP"u8.CopyTo(bytes);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 0x00010000);
var offsets = psx
? new uint[] { 40, 40, 40, 40, 40, 40, 40, 64 }
: new uint[] { 40, 40, 40, 40, 40, 40, 40, 96 };
for (var index = 0; index < offsets.Length; index++)
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), offsets[index]);
if (psx) "PSISOIMG"u8.CopyTo(bytes.AsSpan(64));
else new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F' }.CopyTo(bytes.AsSpan(40));
await using var stream = new MemoryStream(bytes);
var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None);
Assert.Equal(expected, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "PBP");
}
[Fact]
public async Task ReproduciblePspHomebrewFixturePassesTheExactPbpInspector()
{
await using var compressed = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "fixtures", "ludarium-psp-fixture.pbp.gz"));
await using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
await using var fixture = new MemoryStream();
await gzip.CopyToAsync(fixture);
fixture.Position = 0;
var result = await DiscImageAnalysis.InspectPbpAsync(fixture, CancellationToken.None);
Assert.Equal(130008, fixture.Length);
Assert.Equal("psp", result.Platform);
Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value == "psp:PBP:ELF");
}
[Fact]
public async Task PbpInspectorRejectsNonMonotonicOffsets()
{
var bytes = new byte[64];
"\0PBP"u8.CopyTo(bytes);
for (var index = 0; index < 8; index++)
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), (uint)(40 + index));
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24, 4), 39);
await using var stream = new MemoryStream(bytes);
var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None);
Assert.Null(result.Platform);
Assert.Equal(Confidence.None, result.Confidence);
Assert.Empty(result.Evidence);
}
[Fact]
public void NintendoDsHeaderProvidesSemanticEvidence()
{
var header = new byte[0x160];
Encoding.ASCII.GetBytes("SYNTHETIC DS").CopyTo(header, 0);
Encoding.ASCII.GetBytes("ALPE").CopyTo(header, 12);
Encoding.ASCII.GetBytes("01").CopyTo(header, 16);
header[18] = 0; header[30] = 2;
new byte[] { 0x24, 0xff, 0xae, 0x51 }.CopyTo(header, 0xC0);
var result = ArtifactAnalysis.Classify("unknown.bin", header);
Assert.Equal("nds", result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC DS");
Assert.Contains(result.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "ALPE");
Assert.Contains(result.Evidence, item => item.Kind == "rom.region" && item.Value == "usa");
}
[Theory]
[InlineData(0x7FC0)]
[InlineData(0x81C0)]
public void SnesHeadersWithAndWithoutCopierPrefixAreDetected(int headerOffset)
{
var header = new byte[headerOffset + 0x20];
Encoding.ASCII.GetBytes("SYNTHETIC SNES").CopyTo(header, headerOffset);
header[headerOffset + 0x15] = 0x20;
header[headerOffset + 0x19] = 1;
BitConverter.GetBytes((ushort)0xEDCB).CopyTo(header, headerOffset + 0x1C);
BitConverter.GetBytes((ushort)0x1234).CopyTo(header, headerOffset + 0x1E);
var result = ArtifactAnalysis.Classify("cartridge.dat", header);
Assert.Equal(MediaType.Rom, result.MediaType);
Assert.Equal("snes", result.Platform);
Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC SNES");
}
[Fact]
public void InvalidSnesChecksumDoesNotCreateFalseSignature()
{
var header = new byte[0x8000];
header[0x7FD5] = 0x20;
Assert.Equal(MediaType.Unknown, ArtifactAnalysis.Classify("unknown.dat", header).MediaType);
}
[Theory]
[InlineData("Games", "/library/games", "PC/Example/setup.bin", "windows")]
[InlineData("Games", "/library/games", "roms/3ds/Example/game.3ds", "3ds")]
[InlineData("Games", "/library/games", "roms/PS1/Example/disc.cue", "psx")]
[InlineData("Games", "/library/games", "roms/ngc/Example/game.rvz", "gamecube")]
[InlineData("PS4 Games", "/library/ps4", "Example/game.pkg", "ps4")]
[InlineData("PS5 Games", "/library/ps5", "Example/game.pkg", "ps5")]
public void PlatformHintsFollowConfiguredDirectoryTaxonomy(string libraryName, string libraryPath, string relativePath, string expected) =>
Assert.Equal(expected, ArtifactAnalysis.InferPlatformHint(libraryName, libraryPath, relativePath));
[Fact]
public void DirectoryHintClassifiesPlatformWithoutOverridingDeterministicFormatEvidence()
{
var hinted = ArtifactAnalysis.Classify("roms/PS2/game.iso", ReadOnlySpan<byte>.Empty, "ps2");
Assert.Equal("ps2", hinted.Platform);
Assert.Contains(hinted.Evidence, evidence => evidence.Kind == "directory.platform" && evidence.Value == "ps2");
var signatureWins = ArtifactAnalysis.Classify("roms/PS2/misplaced.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, "ps2");
Assert.Equal("nes", signatureWins.Platform);
var multipartVolume = ArtifactAnalysis.Classify("PS5-Release/part.v64", ReadOnlySpan<byte>.Empty, "ps5");
Assert.Equal("ps5", multipartVolume.Platform);
Assert.Equal(MediaType.Unknown, multipartVolume.MediaType);
Assert.Contains(multipartVolume.Evidence, evidence => evidence.Kind == "platform.conflict");
}
private static byte[] CreateSummaryPropertySet(params (int Id, string Value)[] properties)
{
var encoded = properties.Select(property =>
{
var value = Encoding.Latin1.GetBytes(property.Value + '\0');
var length = 8 + value.Length;
var padded = (length + 3) & ~3;
var bytes = new byte[padded];
BitConverter.GetBytes(30).CopyTo(bytes, 0);
BitConverter.GetBytes(value.Length).CopyTo(bytes, 4);
value.CopyTo(bytes, 8);
return (property.Id, Bytes: bytes);
}).ToArray();
var sectionOffset = 48;
var valuesOffset = 8 + encoded.Length * 8;
var sectionSize = valuesOffset + encoded.Sum(item => item.Bytes.Length);
var result = new byte[sectionOffset + sectionSize];
BitConverter.GetBytes((ushort)0xfffe).CopyTo(result, 0);
BitConverter.GetBytes(1).CopyTo(result, 24);
BitConverter.GetBytes(sectionOffset).CopyTo(result, 44);
BitConverter.GetBytes(sectionSize).CopyTo(result, sectionOffset);
BitConverter.GetBytes(encoded.Length).CopyTo(result, sectionOffset + 4);
var cursor = valuesOffset;
for (var index = 0; index < encoded.Length; index++)
{
BitConverter.GetBytes(encoded[index].Id).CopyTo(result, sectionOffset + 8 + index * 8);
BitConverter.GetBytes(cursor).CopyTo(result, sectionOffset + 12 + index * 8);
encoded[index].Bytes.CopyTo(result, sectionOffset + cursor);
cursor += encoded[index].Bytes.Length;
}
return result;
}
private static byte[] CreatePlayStationIso(string? platform)
{
const int sector = 2048;
const int rootLba = 20;
const int configLba = 22;
var image = new byte[24 * sector];
var pvd = image.AsSpan(16 * sector, sector);
pvd[0] = 1;
"CD001"u8.CopyTo(pvd[1..]);
pvd[6] = 1;
var directory = image.AsSpan(rootLba * sector, sector);
var directoryLength = 0;
if (platform is "psp")
{
directoryLength += WriteIsoRecord(directory[directoryLength..], 21, sector, "PSP_GAME");
directoryLength += WriteIsoRecord(directory[directoryLength..], 23, 32, "UMD_DATA.BIN;1");
}
else if (platform is "psx" or "ps2")
{
var config = platform == "ps2" ? "BOOT2 = cdrom0:\\\\SLUS_000.00;1\r\n" : "BOOT = cdrom:\\\\SCUS_000.00;1\r\n";
var configBytes = Encoding.ASCII.GetBytes(config);
configBytes.CopyTo(image, configLba * sector);
directoryLength += WriteIsoRecord(directory[directoryLength..], configLba, configBytes.Length, "SYSTEM.CNF;1");
}
else
{
directoryLength += WriteIsoRecord(directory, 23, 16, "README.TXT;1");
}
WriteIsoRecord(pvd[156..], rootLba, directoryLength, "\0");
return image;
}
private static int WriteIsoRecord(Span<byte> destination, int extent, int dataLength, string name)
{
var nameBytes = Encoding.ASCII.GetBytes(name);
var length = 33 + nameBytes.Length + (nameBytes.Length % 2 == 0 ? 1 : 0);
destination[..length].Clear();
destination[0] = (byte)length;
BitConverter.GetBytes(extent).CopyTo(destination[2..]);
BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(extent)).CopyTo(destination[6..]);
BitConverter.GetBytes(dataLength).CopyTo(destination[10..]);
BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(dataLength)).CopyTo(destination[14..]);
destination[25] = 0;
destination[28] = 1;
destination[31] = 1;
destination[32] = (byte)nameBytes.Length;
nameBytes.CopyTo(destination[33..]);
return length;
}
}
@@ -0,0 +1,96 @@
using System.IO.Compression;
using Ludarium.Application;
using SharpCompress.Common;
using SharpCompress.Writers;
namespace Ludarium.UnitTests;
public sealed class ArchiveAnalysisTests
{
[Fact]
public void EnumeratesCentralDirectoryWithoutExtractingMembers()
{
using var stream = CreateZip(("games/demo.nes", new byte[] { 1, 2, 3 }), ("manual.txt", "offline"u8.ToArray()));
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream);
Assert.True(result.Complete);
Assert.Equal(2, result.Members.Count);
Assert.Contains(result.Members, x => x.Path == "games/demo.nes" && x.UncompressedSize == 3 && x.ContentBlobId is null);
Assert.Equal([0, 1], result.Members.Select(x => x.Sequence));
}
[Fact]
public void RecordsTraversalAsUnsafeAndNeverExtractsIt()
{
using var stream = CreateZip(("../escape.exe", new byte[] { 1 }));
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream);
Assert.False(result.Complete);
Assert.True(Assert.Single(result.Members).UnsafePath);
Assert.Contains(result.Findings, x => x.Contains("never extracted", StringComparison.Ordinal));
}
[Fact]
public void StopsAtConfiguredMemberLimit()
{
using var stream = CreateZip(("one.bin", new byte[] { 1 }), ("two.bin", new byte[] { 2 }));
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream, new ArchiveLimits(MaxMembers: 1));
Assert.Single(result.Members);
Assert.Contains(result.Findings, x => x.Contains("member limit", StringComparison.Ordinal));
}
[Fact]
public void HonorsCancellationBeforeEnumeratingMembers()
{
using var stream = CreateZip(("one.bin", new byte[] { 1 }));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(() =>
ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream, cancellationToken: cancellation.Token));
}
[Fact]
public void EnumeratesSevenZipHeadersWithoutExtractingMembers()
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.7z");
try
{
using (var writer = WriterFactory.OpenWriter(path, ArchiveType.SevenZip, new WriterOptions(CompressionType.LZMA)))
{
using var first = new MemoryStream(new byte[] { 1, 2, 3 });
writer.Write("games/demo.nes", first, null);
using var second = new MemoryStream("offline"u8.ToArray());
writer.Write("manual.txt", second, null);
}
using var stream = File.OpenRead(path);
var result = ArchiveAnalysis.InspectSevenZip(Guid.NewGuid(), stream);
Assert.True(result.Complete);
Assert.Equal(2, result.Members.Count);
Assert.Contains(result.Members, x => x.Path == "games/demo.nes" && x.UncompressedSize == 3);
}
finally { File.Delete(path); }
}
private static MemoryStream CreateZip(params (string Name, byte[] Data)[] entries)
{
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (name, data) in entries)
{
var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize);
using var output = entry.Open();
output.Write(data);
}
}
stream.Position = 0;
return stream;
}
}
@@ -0,0 +1,121 @@
using Ludarium.Domain;
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class ArtworkQualityTests
{
[Theory]
[InlineData(1526, 1370)]
[InlineData(2100, 1536)]
[InlineData(512, 521)]
public void LandscapeOrSquareImagesAreNotCoverQuality(int width, int height)
=> Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High) < ArtworkQuality.VerifiedThreshold);
[Fact]
public void PortraitHighResolutionTrustedArtworkIsVerified() =>
Assert.True(ArtworkQuality.Score(600, 900, 0.6667m, "LaunchBox", Confidence.High) >= ArtworkQuality.VerifiedThreshold);
[Theory]
[InlineData("ps5", 1600, 2040)]
[InlineData("switch", 1000, 1600)]
[InlineData("nds", 1350, 1220)]
[InlineData("3ds", 1300, 1160)]
[InlineData("n64", 1400, 1000)]
[InlineData("snes", 1400, 1000)]
[InlineData("gb", 1000, 1000)]
public void PlatformRetailProfilesVerifyTheirActualFrontCoverGeometry(string platform, int width, int height) =>
Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform) >=
ArtworkQuality.VerifiedThreshold);
[Theory]
[InlineData("amiga-cd32", 1000, 1000)]
[InlineData("fds", 900, 1200)]
[InlineData("n64dd", 1290, 1120)]
[InlineData("atari-lynx", 1300, 1600)]
[InlineData("neo-geo-pocket", 1050, 1200)]
[InlineData("wonderswan-color", 1000, 1500)]
public void NicheRetailProfilesVerifyTheirDocumentedFrontGeometry(string platform, int width, int height) =>
Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform) >=
ArtworkQuality.VerifiedThreshold);
[Theory]
[InlineData("wiiu", "wii-u")]
[InlineData("xbox360", "xbox-360")]
[InlineData("xboxone", "xbox-one")]
[InlineData("xboxseries", "xbox-series")]
[InlineData("mastersystem", "master-system")]
[InlineData("gamegear", "game-gear")]
[InlineData("sega32x", "sega-32x")]
[InlineData("segacd", "sega-cd")]
public void HistoricalScanIdsUseTheirCanonicalRetailProfile(string historical, string canonical) =>
Assert.Equal(ArtworkQuality.ExpectedRatio(canonical), ArtworkQuality.ExpectedRatio(historical));
[Fact]
public void GenericPortraitIsRejectedAsPs5RetailCover() =>
Assert.Equal(0, ArtworkQuality.Score(600, 900, null, "Provider", Confidence.High, "ps5"));
[Theory]
[InlineData("nds", 1220, 1350)]
[InlineData("3ds", 1160, 1300)]
[InlineData("n64", 1000, 1400)]
[InlineData("snes", 1000, 1400)]
public void InvertedOrWrongRegionGeometryIsRejected(string platform, int width, int height) =>
Assert.Equal(0, ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform));
[Fact]
public void SteamLibraryArtCannotClaimToBeRetailPackaging() =>
Assert.Equal(0, ArtworkQuality.Score(1000, 1400, null, "Steam", Confidence.High, "windows"));
[Fact]
public void VerifiedDigitalOnlyFrontUsesItsStorefrontGeometryAndPosterRole()
{
var artwork = new GameArtwork(Guid.NewGuid(), Guid.NewGuid(), ArtworkRole.BoxFront,
ArtworkVerificationStatus.NeedsReview, "SteamDigital", "SteamDigital", "2659050",
"verified-digital-only-storefront-front", Confidence.High, 600, 900, 0.6667m, 100,
new string('a', 64), 0, DateTimeOffset.UtcNow, false, "windows");
var evaluated = ArtworkQuality.EvaluateForPlatform(artwork, "windows");
Assert.True(evaluated.QualityScore >= ArtworkQuality.VerifiedThreshold);
Assert.Equal(ArtworkRole.Poster, evaluated.Role);
Assert.Equal(ArtworkVerificationStatus.AutomaticallyVerified, evaluated.VerificationStatus);
}
[Theory]
[InlineData(512, 357, 1.4342)]
[InlineData(400, 400, 1.0)]
[InlineData(120, 180, 0.6667)]
public void LandscapeSquareAndTinyImagesNeedReview(int width, int height, double ratio) =>
Assert.True(ArtworkQuality.Score(width, height, (decimal)ratio, "Provider", Confidence.High) < ArtworkQuality.VerifiedThreshold);
[Fact]
public void GeneratedArtworkCanNeverClaimVerifiedQuality() =>
Assert.True(ArtworkQuality.Score(1200, 1800, 0.6667m, "Generated", Confidence.Deterministic) < ArtworkQuality.VerifiedThreshold);
[Fact]
public void ManualCoverOverrideSurvivesPlatformEvaluation()
{
var uploaded = new GameArtwork(Guid.NewGuid(), Guid.NewGuid(), ArtworkRole.UserUpload,
ArtworkVerificationStatus.ManuallyVerified, "Uploaded", "Uploaded", null, "operator-upload",
Confidence.Deterministic, 400, 400, 1m, 10, new string('a', 64), 35,
DateTimeOffset.UtcNow, Platform: null);
Assert.Equal(uploaded, ArtworkQuality.EvaluateForPlatform(uploaded, "ps5"));
}
[Fact]
public void BestCandidateUsesQualityThenConfidenceAndNeverSelectsRejectedArtwork()
{
var gameId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
GameArtwork Candidate(decimal score, Confidence confidence, ArtworkVerificationStatus status = ArtworkVerificationStatus.NeedsReview) =>
new(Guid.NewGuid(), gameId, ArtworkRole.BoxFront, status, "fixture", "fixture", null, "fixture", confidence,
600, 900, 0.6667m, 100, new string('a', 64), score, now, false);
var low = Candidate(60, Confidence.Deterministic);
var high = Candidate(90, Confidence.High);
var rejected = Candidate(100, Confidence.Deterministic, ArtworkVerificationStatus.Rejected);
Assert.Equal(high.Id, ArtworkQuality.SelectBest([low, rejected, high])?.Id);
}
}
@@ -0,0 +1,61 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class BrowserGameDataPolicyTests
{
[Fact]
public void VerifiedBindingMatchesOnlyTheExactArtifactRevisionAndCore()
{
var gameId = Guid.NewGuid();
var artifactId = Guid.NewGuid();
var sha256 = new string('a', 64);
var artifact = Artifact(artifactId, version: 4, sha256: sha256);
var session = Session(gameId, artifactId, "fceumm");
var sourceId = BrowserGameDataPolicy.EncodeBinding(artifactId, artifact.Version, session.Core, sha256);
Assert.True(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session, artifact));
Assert.False(BrowserGameDataPolicy.Matches("ManualUpload", sourceId, session, artifact));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, "not-base64", session, artifact));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId,
session with { Core = "mgba" }, artifact));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session,
artifact with { Version = artifact.Version + 1 }));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session,
artifact with { Sha256 = new string('b', 64) }));
}
[Fact]
public void RestoreScopeIsFixtureVerifiedAndStableAcrossCoreCasing()
{
var gameId = Guid.NewGuid();
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("FCEUMM"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("mgba"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("gambatte"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("snes9x"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("melonds"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("ppsspp"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("pcsx_rearmed"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("n64wasm"));
Assert.False(BrowserGameDataPolicy.SupportsAutomaticRestore("mupen64plus_next"));
Assert.Equal(BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.Save),
BrowserGameDataPolicy.StableEntryId(gameId, "FCEUMM", GameDataKind.Save));
Assert.NotEqual(BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.Save),
BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.SaveState));
Assert.Throws<ArgumentException>(() =>
BrowserGameDataPolicy.EncodeBinding(Guid.NewGuid(), 1, "mupen64plus_next", null));
}
private static Artifact Artifact(Guid id, long version, string sha256) =>
new(id, Guid.NewGuid(), "roms/nes/synthetic.nes", 16, DateTimeOffset.UnixEpoch,
ArtifactState.Present, MediaType.Rom, Confidence.High, null, DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch, "ines", "nes", version, sha256);
private static BrowserPlaySession Session(Guid gameId, Guid artifactId, string core) =>
new(Guid.NewGuid(), gameId, artifactId, PlaySessionState.Ready, "nes", "Ludarium EmulatorJS",
core, "/player.html", Convert.ToHexString(Encoding.UTF8.GetBytes("token")),
DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddHours(1));
}
@@ -0,0 +1,274 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class BrowserPlayPolicyTests
{
[Fact]
public void DeterministicNesFixtureIsAllowlistedOnlyWhenSourceIsReadOnly()
{
var fixture = Convert.FromBase64String(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "minimal-ines.base64")).Trim());
Assert.Equal([0x4e, 0x45, 0x53, 0x1a], fixture[..4]);
var candidate = Candidate("nes", "roms/nes/synthetic.nes", fixture.Length, readOnly: true);
var result = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorHealthy: true, DateTimeOffset.UnixEpoch);
Assert.True(result.Available);
Assert.Equal(BrowserPlayState.Available, result.State);
Assert.Equal("fceumm", result.Core);
Assert.True(result.AutomaticRestore);
}
[Theory]
[InlineData("pc", "PC/setup.exe", BrowserPlayState.UnsupportedPlatform)]
[InlineData("nes", "roms/nes/setup.exe", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/launch.bat", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/launch.ps1", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/archive.zip", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/../../private/game.nes", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "C:\\private\\game.nes", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "/private/game.nes", BrowserPlayState.UnsupportedFormat)]
public void NativeExecutableScriptAndNonAllowlistedContentFailClosed(string platform, string path, BrowserPlayState state)
{
var result = BrowserPlayPolicy.Evaluate(Candidate(platform, path, 64, true), true, true, DateTimeOffset.UnixEpoch);
Assert.False(result.Available);
Assert.Equal(state, result.State);
}
[Fact]
public void WritableMissingAndOversizedSourcesFailClosed()
{
Assert.Equal(BrowserPlayState.Disabled, BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", 16, false), true, true, DateTimeOffset.UnixEpoch).State);
Assert.Equal("fceumm", BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", 16, true), false, false, DateTimeOffset.UnixEpoch).Core);
Assert.Equal(BrowserPlayState.MissingRom, BrowserPlayPolicy.Evaluate(null, true, true, DateTimeOffset.UnixEpoch).State);
Assert.Equal(BrowserPlayState.UnsupportedFormat, BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", BrowserPlayPolicy.MaximumRomBytes + 1, true), true, true, DateTimeOffset.UnixEpoch).State);
}
[Fact]
public void SafeNameCannotExposeAStoredPath()
{
Assert.Equal("game.nes", BrowserPlayPolicy.SafeFileName("roms/nes/../../private/game.nes"));
Assert.Equal("game.nes", BrowserPlayPolicy.SafeFileName("roms\\nes\\game.nes"));
}
[Fact]
public void FixtureQualifiedCoreClaimsVerifiedAutomaticRestore()
{
var result = BrowserPlayPolicy.Evaluate(Candidate("gba", "roms/gba/synthetic.gba", 64, true),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(result.Available);
Assert.Equal(BrowserPlayState.Available, result.State);
Assert.Equal("mgba", result.Core);
Assert.True(result.AutomaticRestore);
}
[Fact]
public void N64ZipContainerProceedsOnlyToTheServiceLevelContentValidation()
{
var result = BrowserPlayPolicy.Evaluate(Candidate("n64", "roms/n64/game.zip", 1024, true),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(result.Available);
Assert.Equal("n64wasm", result.Core);
}
[Fact]
public void Ps2UsesItsFixtureProvenDiscLimitWithoutClaimingStateRestore()
{
var belowLimit = BrowserPlayPolicy.Evaluate(
Candidate("ps2", "roms/ps2/personal.iso", BrowserPlayPolicy.MaximumRomBytes + 1, true),
true, true, DateTimeOffset.UnixEpoch);
var aboveLimit = BrowserPlayPolicy.Evaluate(
Candidate("ps2", "roms/ps2/personal.iso", 8L * 1024 * 1024 * 1024 + 1, true),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(belowLimit.Available);
Assert.Equal("playjs", belowLimit.Core);
Assert.Equal("Ludarium Play!.js", belowLimit.Emulator);
Assert.False(belowLimit.AutomaticRestore);
Assert.Equal(BrowserPlayState.UnsupportedFormat, aboveLimit.State);
}
[Fact]
public void EveryAdvertisedCoreHasAReleaseFixture()
{
var qualified = BrowserPlayPolicy.DescribePlatforms().Where(platform => platform.ReleaseQualified).ToArray();
Assert.Equal(13, qualified.Length);
Assert.Contains(qualified, platform => platform.Platform == "nes" && platform.Core == "fceumm");
Assert.Contains(qualified, platform => platform.Platform == "snes" && platform.Core == "snes9x");
Assert.Contains(qualified, platform => platform.Platform == "gb" && platform.Core == "gambatte");
Assert.Contains(qualified, platform => platform.Platform == "gbc" && platform.Core == "gambatte");
Assert.Contains(qualified, platform => platform.Platform == "gba" && platform.Core == "mgba");
Assert.Contains(qualified, platform => platform.Platform == "nds" && platform.Core == "melonds");
Assert.Contains(qualified, platform => platform.Platform == "psp" && platform.Core == "ppsspp");
Assert.Contains(qualified, platform => platform.Platform == "psx" && platform.Core == "pcsx_rearmed");
Assert.Contains(qualified, platform => platform.Platform == "ps2" && platform.Core == "playjs" &&
platform.MaximumBytes == 8L * 1024 * 1024 * 1024);
Assert.Contains(qualified, platform => platform.Platform == "n64" && platform.Core == "n64wasm" &&
platform.MaximumBytes == BrowserPlayPolicy.MaximumRomBytes);
foreach (var platform in new[] { "genesis", "mastersystem", "gamegear" })
Assert.Contains(qualified, entry => entry.Platform == platform && entry.Core == "genesis_plus_gx");
}
private static BrowserPlayCandidate Candidate(string platform, string path, long size, bool readOnly) =>
new(Guid.NewGuid(), Guid.NewGuid(), "Synthetic", platform, path, size, null, readOnly);
[Fact]
public void ACueSheetWinsOverTheTracksItDescribes()
{
var gameId = Guid.NewGuid();
var sheet = Candidate(gameId, "roms/psx/Game/Game.cue", 512);
var track = Candidate(gameId, "roms/psx/Game/Game (Track 1).bin", 700_000_000);
var audio = Candidate(gameId, "roms/psx/Game/Game (Track 2).bin", 40_000_000);
// Without this the tracks make every multi-track disc look ambiguous and unplayable.
Assert.Same(sheet, BrowserPlayPolicy.SelectCandidate([track, sheet, audio]));
Assert.Same(track, BrowserPlayPolicy.SelectCandidate([track]));
}
[Fact]
public void APlaylistWinsOverTheDiscsAndTracksItNames()
{
var gameId = Guid.NewGuid();
var playlist = Candidate(gameId, "roms/psx/Game/Game.m3u", 64);
var firstDisc = Candidate(gameId, "roms/psx/Game/Game (Disc 1).cue", 512);
var secondDisc = Candidate(gameId, "roms/psx/Game/Game (Disc 2).cue", 512);
var track = Candidate(gameId, "roms/psx/Game/Game (Disc 1) (Track 1).bin", 700_000_000);
// Descriptors outrank what they describe, most general first.
Assert.Same(playlist, BrowserPlayPolicy.SelectCandidate([firstDisc, track, playlist, secondDisc]));
Assert.Null(BrowserPlayPolicy.SelectCandidate([firstDisc, secondDisc, track]));
}
[Fact]
public void AMultiDiscGameIsDeliverableButNotYetQualified()
{
var psx = BrowserPlayPolicy.GetPlatform("psx")!;
Assert.True(psx.Accepts(".m3u"));
Assert.False(psx.IsQualified(".m3u"));
Assert.False(BrowserPlayPolicy.GetPlatform("nes")!.Accepts(".m3u"));
}
[Fact]
public void TwoSheetsOrTwoDiscsStayUnresolvable()
{
var gameId = Guid.NewGuid();
Assert.Null(BrowserPlayPolicy.SelectCandidate([
Candidate(gameId, "roms/psx/Game/Disc 1.cue", 512),
Candidate(gameId, "roms/psx/Game/Disc 2.cue", 512)]));
Assert.Null(BrowserPlayPolicy.SelectCandidate([
Candidate(gameId, "roms/psx/Game/Disc 1.chd", 500_000_000),
Candidate(gameId, "roms/psx/Game/Disc 2.chd", 500_000_000)]));
Assert.Null(BrowserPlayPolicy.SelectCandidate([]));
}
[Fact]
public void SegaPlatformsAreReleaseQualifiedByTheirDeterministicFixtures()
{
foreach (var platform in new[] { "genesis", "mastersystem", "gamegear" })
{
var mapping = BrowserPlayPolicy.GetPlatform(platform);
Assert.NotNull(mapping);
Assert.Equal("genesis_plus_gx", mapping.Core);
// Rule 10: each of these completed the live core gate on its own pinned fixture, which
// booted genesis_plus_gx, captured a savestate and restored it into a relaunched session.
Assert.True(mapping.ReleaseQualified);
Assert.Contains("genesis_plus_gx", BrowserPlayPolicy.RequiredCores);
}
// The savestate half of that evidence is what lets a session restore without being asked.
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("genesis_plus_gx"));
}
[Fact]
public void EveryMappedCoreIsRequiredExceptLudariumsOwnPlayers()
{
Assert.DoesNotContain("playjs", BrowserPlayPolicy.RequiredCores);
Assert.DoesNotContain("n64wasm", BrowserPlayPolicy.RequiredCores);
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
if (platform.Core is not "playjs" and not "n64wasm")
Assert.Contains(platform.Core, BrowserPlayPolicy.RequiredCores);
}
[Fact]
public void ACueSetIsDeliverableForPlayStationButNotForPlayJs()
{
var psx = BrowserPlayPolicy.GetPlatform("psx")!;
var ps2 = BrowserPlayPolicy.GetPlatform("ps2")!;
// EmulatorJS mounts the streamed archive; Play!.js boots a raw disc image and cannot.
Assert.True(psx.Accepts(".cue"));
Assert.False(ps2.Accepts(".cue"));
Assert.False(BrowserPlayPolicy.GetPlatform("nes")!.Accepts(".cue"));
}
[Fact]
public void ADeliverableButUnqualifiedFormatFailsClosedWithAnExactReason()
{
var gameId = Guid.NewGuid();
var qualified = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.chd", 500_000_000),
true, true, DateTimeOffset.UnixEpoch);
var candidate = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.cue", 512),
true, true, DateTimeOffset.UnixEpoch);
var unknown = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.rar", 512),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(qualified.Available);
// Rule 10: an implemented delivery path is not a support claim until a fixture proves it.
Assert.False(candidate.Available);
Assert.Equal(BrowserPlayState.UnsupportedFormat, candidate.State);
Assert.Contains("not release-qualified", candidate.Message, StringComparison.Ordinal);
Assert.False(unknown.Available);
Assert.Contains("not allowlisted", unknown.Message, StringComparison.Ordinal);
}
[Fact]
public void OnlyTheFixtureProvenPlayStation2ContainerIsClaimedPlayable()
{
var ps2 = BrowserPlayPolicy.GetPlatform("ps2")!;
// Play!.js range-reads a raw block device and boots it as an ISO. Only the ISO fixture has
// completed the live gate, so a compressed container is deliverable, never claimed.
Assert.True(ps2.IsQualified(".iso"));
foreach (var candidate in new[] { ".cso", ".chd", ".isz", ".bin" })
{
Assert.True(ps2.Accepts(candidate), candidate);
Assert.False(ps2.IsQualified(candidate), candidate);
Assert.False(BrowserPlayPolicy.Evaluate(
Candidate(Guid.NewGuid(), $"roms/ps2/Game/Game{candidate}", 4_000_000_000),
true, true, DateTimeOffset.UnixEpoch).Available, candidate);
}
Assert.True(BrowserPlayPolicy.Evaluate(Candidate(Guid.NewGuid(), "roms/ps2/Game/Game.iso", 4_000_000_000),
true, true, DateTimeOffset.UnixEpoch).Available);
}
[Theory]
[InlineData("snes", ".sfc", ".smc")]
[InlineData("psx", ".chd", ".pbp")]
[InlineData("psp", ".pbp", ".iso")]
[InlineData("psp", ".pbp", ".cso")]
[InlineData("genesis", ".md", ".gen")]
[InlineData("genesis", ".md", ".smd")]
[InlineData("genesis", ".md", ".bin")]
public void ContainerClaimsStayBoundToTheExactLiveFixture(string platform, string qualifiedExtension,
string candidateExtension)
{
var mapping = BrowserPlayPolicy.GetPlatform(platform)!;
Assert.True(mapping.IsQualified(qualifiedExtension));
Assert.True(mapping.Accepts(candidateExtension));
Assert.False(mapping.IsQualified(candidateExtension));
var result = BrowserPlayPolicy.Evaluate(
Candidate(Guid.NewGuid(), $"roms/{platform}/Fixture/Game{candidateExtension}", 64 * 1024),
true, true, DateTimeOffset.UnixEpoch);
Assert.False(result.Available);
Assert.Equal(BrowserPlayState.UnsupportedFormat, result.State);
Assert.Contains("not release-qualified", result.Message, StringComparison.Ordinal);
}
private static BrowserPlayCandidate Candidate(Guid gameId, string path, long size) =>
new(gameId, Guid.NewGuid(), "Fixture", path.Split('/')[1], path, size, null, true);
}
@@ -0,0 +1,49 @@
using System.Text;
using System.Xml;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class CatalogImportTests
{
[Fact]
public async Task LogiqxImportIsVersionedStreamingAndDeterministic()
{
const string xml = """
<?xml version="1.0"?>
<datafile><header><name>Synthetic</name></header><game name="Example Game">
<rom name="example.nes" size="16" crc="CBF43926" sha1="a9993e364706816aba3e25717850c26c9cd0d89d" sha256="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" />
<rom name="ignored.bin" size="4" />
</game></datafile>
""";
var store = new CapturingCatalogStore();
var importer = new LogiqxCatalogImporter(store);
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml));
var source = await importer.ImportLogiqxAsync("Synthetic DAT", "2026.07", input, CancellationToken.None);
Assert.Equal("Ready", source.State); Assert.Equal(1, source.EntryCount); Assert.Equal(64, source.Sha256?.Length);
var entry = Assert.Single(store.Entries);
Assert.Equal("Example Game", entry.GameName); Assert.Equal("cbf43926", entry.Crc32);
Assert.Equal(1, store.MatchCalls);
}
[Fact]
public async Task LogiqxImportRejectsDocumentTypes()
{
const string xml = "<!DOCTYPE datafile [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><datafile><game name='x'><rom name='x' crc='00000000'/></game></datafile>";
var importer = new LogiqxCatalogImporter(new CapturingCatalogStore());
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml));
await Assert.ThrowsAsync<XmlException>(() => importer.ImportLogiqxAsync("Hostile", "1", input, CancellationToken.None));
}
private sealed class CapturingCatalogStore : ICatalogStore
{
public List<CatalogEntry> Entries { get; } = [];
public int MatchCalls { get; private set; }
public Task UpsertCatalogSourceAsync(CatalogSource source, CancellationToken cancellationToken) => Task.CompletedTask;
public Task UpsertCatalogEntriesAsync(IReadOnlyList<CatalogEntry> entries, CancellationToken cancellationToken) { Entries.AddRange(entries); return Task.CompletedTask; }
public Task<IReadOnlyList<CatalogSource>> ListCatalogSourcesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<CatalogSource>>([]);
public Task<int> MatchCatalogAsync(Guid sourceId, CancellationToken cancellationToken) { MatchCalls++; return Task.FromResult(Entries.Count); }
public Task<IReadOnlyList<MatchCandidate>> ListMatchCandidatesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<MatchCandidate>>([]);
}
}
@@ -0,0 +1,19 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class CatalogVisibilityTests
{
[Theory]
[InlineData("Steam")]
[InlineData("FMT-v23.2")]
[InlineData("Frosty Mod Tool 1.0")]
[InlineData("Game DLC Unlocker")]
public void SupportTitlesAreExcluded(string title) => Assert.False(CatalogVisibility.IsVisibleTitle(title));
[Theory]
[InlineData("SteamWorld Dig")]
[InlineData("Frostpunk")]
[InlineData("A regular game")]
public void RealGamesRemainVisible(string title) => Assert.True(CatalogVisibility.IsVisibleTitle(title));
}
@@ -0,0 +1,22 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class ClaimPolicyTests
{
[Fact]
public void LockedManualClaimOverridesAutomaticEvidenceAndSupersessionIsAppendOnly()
{
var entity = Guid.NewGuid(); var now = DateTimeOffset.UtcNow;
var automatic = Claim(Guid.NewGuid(), entity, "Automatic title", Confidence.Deterministic, false, now);
var manual = Claim(Guid.NewGuid(), entity, "Operator title", Confidence.High, true, now.AddSeconds(1));
var corrected = Claim(Guid.NewGuid(), entity, "Corrected title", Confidence.Deterministic, true, now.AddSeconds(2), manual.Id);
Assert.Equal("Operator title", ClaimPolicy.SelectEffective([automatic, manual])?.Value);
Assert.Equal("Corrected title", ClaimPolicy.SelectEffective([automatic, manual, corrected])?.Value);
Assert.Equal(3, new[] { automatic, manual, corrected }.Length);
}
private static MetadataClaim Claim(Guid id, Guid entity, string value, Confidence confidence, bool locked, DateTimeOffset at, Guid? supersedes = null) =>
new(id, "Game", entity, "title", value, locked ? "Manual" : "Analyzer", "test", confidence, [], locked, at, supersedes);
}
@@ -0,0 +1,24 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class DashboardSummaryTests
{
[Fact]
public void ConfidenceIsUnknownWithoutPresentArtifacts()
{
Assert.Null(Summary(0, 0, 0).ConfidencePercent);
}
[Theory]
[InlineData(100, 100, 100, 100)]
[InlineData(100, 50, 50, 50)]
[InlineData(4, 3, 2, 62.5)]
public void ConfidenceUsesOnlyMeasuredEvidence(long present, long hashed, long recognized, decimal expected)
{
Assert.Equal(expected, Summary(present, hashed, recognized).ConfidencePercent);
}
private static DashboardSummary Summary(long present, long hashed, long recognized) =>
new(0, present, present, 0, 0, 0, 0, hashed, recognized, 0, 0, 0, 0, 0, 0, 0, 0);
}
@@ -0,0 +1,53 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class DolphinLaunchPolicyTests
{
[Fact]
public void UniqueReadOnlyFixtureIsReleaseEligible()
{
var gameId = Guid.NewGuid();
var candidate = Candidate("roms/wii/Synthetic.wbfs", true);
var capability = DolphinLaunchPolicy.EvaluateCapability(gameId, "wii", true, [candidate],
DateTimeOffset.UnixEpoch, vaultBacked: true);
Assert.True(capability.Available);
Assert.True(capability.AutomaticRestore);
Assert.Equal("Dolphin", capability.Emulator);
Assert.Equal("dolphin", capability.Player);
Assert.Equal("wii", capability.Platform);
Assert.True(capability.SupportsSavestates);
// Automatic restore is a live runtime property, never an assumption about the sidecar.
Assert.False(DolphinLaunchPolicy.EvaluateCapability(gameId, "wii", true, [candidate],
DateTimeOffset.UnixEpoch).AutomaticRestore);
Assert.Equal("wii/Synthetic.wbfs", DolphinLaunchPolicy.ToRuntimePath("wii", candidate.RelativePath, "roms/wii"));
}
[Fact]
public void AmbiguousWritableAndUnsafeSourcesFailClosed()
{
var first = Candidate("roms/gamecube/One.rvz", true);
var second = Candidate("roms/gamecube/Two.iso", true);
Assert.Null(DolphinLaunchPolicy.SelectGame([first, second]));
Assert.Null(DolphinLaunchPolicy.SelectGame([first with { SourceReadOnly = false }]));
Assert.Null(DolphinLaunchPolicy.SelectGame([first with { RelativePath = "../One.rvz" }]));
Assert.False(DolphinLaunchPolicy.EvaluateCapability(Guid.NewGuid(), "gamecube", true,
[first, second]).Available);
}
[Theory]
[InlineData("ps2", true, NativeRemotePlayState.UnsupportedPlatform)]
[InlineData("wii", false, NativeRemotePlayState.RuntimeUnavailable)]
public void UnsupportedPlatformAndMissingRuntimeStayUnavailable(string platform, bool runtime,
NativeRemotePlayState state)
{
var capability = DolphinLaunchPolicy.EvaluateCapability(Guid.NewGuid(), platform, runtime, []);
Assert.Equal(state, capability.State);
}
private static NativeLaunchCandidate Candidate(string path, bool readOnly) =>
new(Guid.NewGuid(), path, 2 * 1024 * 1024, readOnly);
}
@@ -0,0 +1,33 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class FirmwarePolicyTests
{
[Fact]
public void PersonalFirmwareSlotsAreExplicitAndRuntimeClaimsStayHonest()
{
var requirements = FirmwarePolicy.Describe();
Assert.Contains(requirements, item => item.Platform == "psx" && item.Slot == "bios" && item.BrowserDelivery);
Assert.Contains(requirements, item => item.Platform == "ps2" && item.Kind == FirmwareAssetKind.Bios && !item.BrowserDelivery);
Assert.Equal(2, requirements.Count(item => item.Platform == "switch" && item.Kind == FirmwareAssetKind.KeySet && !item.BrowserDelivery));
Assert.All(requirements, item => Assert.Contains("runtime", item.RuntimeState, StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("psx", "bios", "scph.bin")]
[InlineData("ps2", "bios", "personal.rom")]
[InlineData("switch", "prod-keys", "prod.keys")]
public void AllowlistedNamesAreAccepted(string platform, string slot, string fileName) =>
Assert.Equal(fileName, FirmwarePolicy.ValidateFileName(FirmwarePolicy.Get(platform, slot), fileName));
[Theory]
[InlineData("psx", "bios", "setup.exe")]
[InlineData("switch", "prod-keys", "keys.zip")]
[InlineData("switch", "firmware", "firmware.bin")]
public void UnknownSlotsAndUnsafeExtensionsFailClosed(string platform, string slot, string fileName) =>
Assert.Throws<FirmwareValidationException>(() =>
FirmwarePolicy.ValidateFileName(FirmwarePolicy.Get(platform, slot), fileName));
}
@@ -0,0 +1,34 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class GameDataVaultPolicyTests
{
[Fact]
public void MetadataIsTrimmedAndBounded()
{
var result = GameDataVaultPolicy.Normalize(new GameDataEntryInput(GameDataKind.Save,
" Main slot ", " mGBA ", " Steam Deck ", " Before the final dungeon "));
Assert.Equal("Main slot", result.Name);
Assert.Equal("mGBA", result.Emulator);
Assert.Equal("Steam Deck", result.Device);
}
[Theory]
[InlineData("../slot.sav")]
[InlineData("folder\\slot.sav")]
[InlineData("payload.exe")]
[InlineData("script.ps1")]
public void UnsafeFileNamesAreRejected(string fileName) =>
Assert.Throws<ArgumentException>(() => GameDataVaultPolicy.ValidateFileName(fileName));
[Fact]
public void NormalSaveFileNameIsPreserved() =>
Assert.Equal("campaign-slot-1.srm", GameDataVaultPolicy.ValidateFileName("campaign-slot-1.srm"));
[Fact]
public void UnknownKindIsRejected() =>
Assert.Throws<ArgumentException>(() => GameDataVaultPolicy.Normalize(
new GameDataEntryInput((GameDataKind)999, "Slot", null, null, null)));
}
@@ -0,0 +1,88 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
/// <summary>
/// The library browser's question to the catalog.
/// </summary>
/// <remarks>
/// Filtering and ordering are properties of the query, not of the page the browser happens to hold.
/// The playable-copy filter in particular must stay derived from the players' own policies, so it
/// can never offer a format nothing accepts.
/// </remarks>
public sealed class GameQueryTests
{
[Fact]
public void AnEmptyQueryAsksForTheWholeCatalog()
{
var query = new GameQuery();
Assert.False(query.HasFilters);
Assert.Equal(1, query.Page);
Assert.Equal(50, query.PageSize);
Assert.Null(query.Sort);
}
[Theory]
[InlineData("title")]
[InlineData("recent")]
[InlineData("updated")]
[InlineData("played")]
[InlineData("rating")]
public void EverySortKeyTheInterfaceOffersIsPartOfTheContract(string sort) =>
Assert.Contains(sort, GameQuery.SortKeys);
[Fact]
public void EveryLibraryFilterCountsAsAFilter()
{
Assert.True(new GameQuery { Search = "mario" }.HasFilters);
Assert.True(new GameQuery { Platform = "snes" }.HasFilters);
Assert.True(new GameQuery { Favorite = true }.HasFilters);
Assert.True(new GameQuery { Status = GamePlayStatus.Playing }.HasFilters);
Assert.True(new GameQuery { CollectionId = Guid.NewGuid() }.HasFilters);
Assert.True(new GameQuery { Tag = "co-op" }.HasFilters);
Assert.True(new GameQuery { MinimumRating = 4 }.HasFilters);
Assert.True(new GameQuery { PlayableOnly = true }.HasFilters);
// Sorting and paging change the order, not the population.
Assert.False(new GameQuery { Sort = "rating", Page = 3 }.HasFilters);
}
[Fact]
public void PlayableFormatsCoverEveryFormatAPlayerAccepts()
{
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
foreach (var extension in platform.Extensions)
Assert.Contains($"{platform.Platform}|{extension}".ToLowerInvariant(), PlayableFormats.Pairs);
foreach (var player in NativeRemotePlayerRegistry.All)
foreach (var owned in player.Platforms)
foreach (var extension in player.Extensions)
Assert.Contains($"{owned}|{extension}".ToLowerInvariant(), PlayableFormats.Pairs);
}
[Fact]
public void PlayableFormatsNeverOfferAPairNoPlayerAccepts()
{
Assert.DoesNotContain("nes|.iso", PlayableFormats.Pairs);
Assert.DoesNotContain("wii|.nes", PlayableFormats.Pairs);
// Play!.js cannot mount an archive, so a PS2 CUE set is not a playable copy.
Assert.DoesNotContain("ps2|.cue", PlayableFormats.Pairs);
Assert.Contains("psx|.cue", PlayableFormats.Pairs);
Assert.Contains("gamecube|.rvz", PlayableFormats.Pairs);
}
[Fact]
public void PlayableFormatsAreOrderedLowerCasedPairs()
{
Assert.All(PlayableFormats.Pairs, pair =>
{
Assert.Equal(pair.ToLowerInvariant(), pair);
var parts = pair.Split('|');
Assert.Equal(2, parts.Length);
Assert.StartsWith(".", parts[1], StringComparison.Ordinal);
});
Assert.Equal(PlayableFormats.Pairs.Order(StringComparer.Ordinal), PlayableFormats.Pairs);
Assert.Equal(PlayableFormats.Pairs.Distinct(), PlayableFormats.Pairs);
}
}
@@ -0,0 +1,18 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class HealthRuleTests
{
[Fact]
public void ChangedContentIsCriticalAndUnknownContentIsNotice()
{
var unknown = new Artifact(Guid.NewGuid(), Guid.NewGuid(), "mystery.bin", 1, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Unknown, Confidence.None, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null);
Assert.Equal(Severity.Notice, HealthRules.EvaluateArtifact(unknown)?.Severity);
var root = LibraryRoot.Create("Unsafe", Path.GetTempPath(), LibraryKind.Mixed) with { IsAvailable = true, IsReadOnly = false };
Assert.Equal(Severity.Warning, HealthRules.EvaluateRoot(root)?.Severity);
var bundle = new Bundle(Guid.NewGuid(), BundleKind.CueBin, "Disc", BundleState.Incomplete, [], ["missing"]);
Assert.Equal(Severity.Warning, HealthRules.EvaluateBundle(bundle)?.Severity);
}
}
@@ -0,0 +1,27 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class LibraryExperiencePolicyTests
{
[Fact]
public void SmartCollectionRulesNormalizeTagsAndPlatform()
{
var input = LibraryExperiencePolicy.Normalize(new GameCollectionInput(" Favorites ", " curated ",
CollectionKind.Smart, new CollectionRule(Platform: "SNES", Tags: [" Co-Op ", "co-op"]), true));
Assert.Equal("Favorites", input.Name);
Assert.Equal("snes", input.Rule?.Platform);
Assert.Equal(["co-op"], Assert.IsType<string[]>(input.Rule?.Tags));
}
[Fact]
public void PersonalStateRejectsRatingsOutsideDocumentedRange() =>
Assert.Throws<ArgumentException>(() => LibraryExperiencePolicy.Normalize(new GameUserStateInput(false,
GamePlayStatus.Backlog, 11, null, null, null, 0, null)));
[Fact]
public void ExternalMediaRejectsInsecureUrls() =>
Assert.Throws<ArgumentException>(() => LibraryExperiencePolicy.Normalize(new GameMediaInput(
GameMediaKind.Screenshot, "Screenshot", "http://example.test/image.png")));
}
@@ -0,0 +1,30 @@
<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.Domain\Ludarium.Domain.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Application\Ludarium.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\fixtures\synthetic\browserplay\minimal-ines.base64" Link="fixtures\minimal-ines.base64" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\fixtures\synthetic\browserplay\psp\ludarium-psp-fixture.pbp.gz" Link="fixtures\ludarium-psp-fixture.pbp.gz" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,176 @@
using System.Buffers.Binary;
using System.Text;
using Ludarium.Application;
using OpenMcdf;
namespace Ludarium.UnitTests;
public sealed class MsiDatabaseAnalysisTests
{
[Fact]
public void ReadsProductMediaAndFileRelationshipsWithoutInstallerApis()
{
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: true);
try
{
var result = MsiDatabaseAnalysis.Inspect(path);
Assert.Equal("Ludarium Fixture", result.Properties["ProductName"]);
Assert.Equal("2.4.1", result.Properties["ProductVersion"]);
Assert.Equal("1033", result.Properties["ProductLanguage"]);
Assert.Equal("Intel;1033", result.Properties["Template"]);
var cabinet = Assert.Single(result.Cabinets);
Assert.True(cabinet.Embedded);
Assert.True(cabinet.StreamPresent);
Assert.Equal("media1.cab", cabinet.Cabinet);
var file = Assert.Single(result.Files);
Assert.Equal("game.exe", file.FileName);
Assert.Equal(4096, file.Size);
Assert.Equal(1, file.Sequence);
Assert.True(result.IsComplete);
}
finally { File.Delete(path); }
}
[Fact]
public void ReportsMissingEmbeddedCabinetWithoutExtractingIt()
{
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: false);
try
{
var result = MsiDatabaseAnalysis.Inspect(path);
Assert.False(result.IsComplete);
Assert.Contains(result.Findings, finding => finding.Contains("missing", StringComparison.OrdinalIgnoreCase));
}
finally { File.Delete(path); }
}
[Fact]
public void DistinguishesExternalCabinetRelationships()
{
var path = CreateFixture(embeddedCabinet: false, includeCabinetStream: false);
try
{
var cabinet = Assert.Single(MsiDatabaseAnalysis.Inspect(path).Cabinets);
Assert.False(cabinet.Embedded);
Assert.False(cabinet.StreamPresent);
Assert.Equal("media1.cab", cabinet.Cabinet);
}
finally { File.Delete(path); }
}
[Fact]
public void RejectsTruncatedAndOversizedMetadataStreams()
{
var truncated = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
var oversized = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
try
{
using (var root = RootStorage.Create(truncated))
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write([0xE4, 0x04, 0, 0, 1]);
Assert.ThrowsAny<Exception>(() => MsiDatabaseAnalysis.Inspect(truncated));
using (var root = RootStorage.Create(oversized))
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write(new byte[8 * 1024 * 1024 + 1]);
Assert.Throws<InvalidDataException>(() => MsiDatabaseAnalysis.Inspect(oversized));
}
finally { File.Delete(truncated); File.Delete(oversized); }
}
private static string CreateFixture(bool embeddedCabinet, bool includeCabinetStream)
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
var columns = new Dictionary<string, (string Name, ushort Type)[]>(StringComparer.Ordinal)
{
["Property"] = [("Property", 0x0d48), ("Value", 0x1dff)],
["Media"] = [("DiskId", 0x2502), ("LastSequence", 0x0502), ("DiskPrompt", 0x1dff),
("Cabinet", 0x1dff), ("VolumeLabel", 0x1d20), ("Source", 0x1d48)],
["File"] = [("File", 0x2d48), ("Component_", 0x0d48), ("FileName", 0x0dff),
("FileSize", 0x0104), ("Version", 0x1d48), ("Language", 0x1d14),
("Attributes", 0x0502), ("Sequence", 0x0502)]
};
var propertyRows = new[]
{
new[] { "ProductName", "Ludarium Fixture" }, new[] { "ProductVersion", "2.4.1" },
new[] { "ProductLanguage", "1033" }, new[] { "Manufacturer", "Ludarium" },
new[] { "ProductCode", "{11111111-2222-3333-4444-555555555555}" }, new[] { "Template", "Intel;1033" }
};
var mediaRows = new[] { new[] { "1", "1", "Install media", embeddedCabinet ? "#media1.cab" : "media1.cab", "LUDARIUM", "" } };
var fileRows = new[] { new[] { "GameExe", "GameComponent", "game.exe", "4096", "2.4.1", "1033", "0", "1" } };
var strings = columns.SelectMany(table => new[] { table.Key }.Concat(table.Value.Select(column => column.Name)))
.Concat(propertyRows.SelectMany(row => row)).Concat(mediaRows.SelectMany(row => row))
.Concat(fileRows.SelectMany(row => row)).Where(value => value.Length > 0)
.Distinct(StringComparer.Ordinal).ToList();
var ids = strings.Select((value, index) => (value, id: (ushort)(index + 1))).ToDictionary(item => item.value, item => item.id, StringComparer.Ordinal);
using (var root = RootStorage.Create(path))
{
var data = strings.SelectMany(value => Encoding.Latin1.GetBytes(value)).ToArray();
var pool = new byte[4 + strings.Count * 4];
BinaryPrimitives.WriteUInt16LittleEndian(pool, 1252);
for (var index = 0; index < strings.Count; index++)
{
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(4 + index * 4, 2), (ushort)Encoding.Latin1.GetByteCount(strings[index]));
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(6 + index * 4, 2), 1);
}
Write(root, "_StringPool", pool); Write(root, "_StringData", data);
var columnRows = columns.SelectMany(table => table.Value.Select((column, index) => new[]
{ ids[table.Key], (ushort)(index + 1), ids[column.Name], column.Type })).ToArray();
Write(root, "_Columns", WriteU16Columns(columnRows));
Write(root, "Property", WriteTable(columns["Property"], propertyRows, ids));
Write(root, "Media", WriteTable(columns["Media"], mediaRows, ids));
Write(root, "File", WriteTable(columns["File"], fileRows, ids));
if (includeCabinetStream) Write(root, "media1.cab", "MSCF"u8.ToArray());
}
return path;
}
private static byte[] WriteTable((string Name, ushort Type)[] columns, string[][] rows, Dictionary<string, ushort> ids)
{
using var output = new MemoryStream();
foreach (var (column, columnIndex) in columns.Select((value, index) => (value, index)))
foreach (var row in rows)
{
var value = row[columnIndex];
var integer = (column.Type & 0x0f00) < 0x0800;
var width = integer && (column.Type & 0x0fff) == 0x0104 ? 4 : 2;
var raw = integer ? (long.Parse(value, System.Globalization.CultureInfo.InvariantCulture) + (width == 4 ? 0x80000000L : 0x8000L)) : value.Length == 0 ? 0 : ids[value];
Span<byte> bytes = new byte[4];
if (width == 4) BinaryPrimitives.WriteUInt32LittleEndian(bytes, (uint)raw);
else BinaryPrimitives.WriteUInt16LittleEndian(bytes, (ushort)raw);
output.Write(bytes[..width]);
}
return output.ToArray();
}
private static byte[] WriteU16Columns(ushort[][] rows)
{
var bytes = new byte[rows.Length * rows[0].Length * 2];
for (var column = 0; column < rows[0].Length; column++)
for (var row = 0; row < rows.Length; row++)
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan((column * rows.Length + row) * 2, 2), rows[row][column]);
return bytes;
}
private static void Write(RootStorage root, string name, byte[] bytes)
{
using var stream = root.CreateStream(Encode(name));
stream.Write(bytes);
}
private static string Encode(string name)
{
const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._!";
var output = new StringBuilder();
for (var index = 0; index < name.Length; index += 2)
{
var first = alphabet.IndexOf(name[index]);
var second = index + 1 < name.Length ? alphabet.IndexOf(name[index + 1]) : -1;
if (first >= 0 && second >= 0) output.Append((char)(0x3800 + first + (second << 6)));
else if (first >= 0) output.Append((char)(0x4800 + first));
else { output.Append(name[index]); if (index + 1 < name.Length) { output.Append(name[index + 1]); index++; } }
}
return output.ToString();
}
}
@@ -0,0 +1,205 @@
using System.Buffers.Binary;
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
/// <summary>
/// Platform identification for the container formats the players actually accept.
/// </summary>
/// <remarks>
/// A disc or cartridge that classifies as Unknown never becomes a launch candidate, so a format the
/// Dolphin policy or the browser matrix allows must also be identifiable here. These fixtures are
/// synthesized from the documented on-media headers, not from copyrighted dumps.
/// </remarks>
public sealed class PlatformIdentificationTests
{
[Fact]
public void GameCubeDiscsAreIdentifiedByTheirMagicWordNotTheirDirectory()
{
var classification = ArtifactAnalysis.Classify("library/unsorted/Disc.iso", GameCubeDisc());
Assert.Equal(MediaType.DiscImage, classification.MediaType);
Assert.Equal("gamecube", classification.Platform);
Assert.Equal(Confidence.Deterministic, classification.Confidence);
Assert.Contains(classification.Evidence, item => item.Kind == "signature" && item.Value == "GameCube disc");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "GLZE");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.region" && item.Value == "usa");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.title" && item.Value == "LUDARIUM GAMECUBE FIXTURE");
}
[Fact]
public void WiiDiscsAreIdentifiedByTheirMagicWordNotTheirDirectory()
{
var classification = ArtifactAnalysis.Classify("library/unsorted/Disc.iso", WiiDisc());
Assert.Equal(MediaType.DiscImage, classification.MediaType);
Assert.Equal("wii", classification.Platform);
Assert.Equal(Confidence.Deterministic, classification.Confidence);
Assert.Contains(classification.Evidence, item => item.Kind == "signature" && item.Value == "Wii disc");
}
[Fact]
public void ADiscMagicOverridesAMisleadingPlatformDirectory()
{
var classification = ArtifactAnalysis.Classify("roms/gamecube/Disc.iso", WiiDisc(), "gamecube");
Assert.Equal("wii", classification.Platform);
Assert.Contains(classification.Evidence, item => item.Kind == "platform.conflict" &&
item.Value == "directory:gamecube;signature:wii");
}
[Theory]
[InlineData("roms/gamecube/Game.gcm", "gamecube")]
[InlineData("roms/gamecube/Game.gcz", "gamecube")]
[InlineData("roms/wii/Game.wia", "wii")]
[InlineData("roms/wii/Game.rvz", "wii")]
[InlineData("roms/wii/Game.wbfs", "wii")]
public void EveryDolphinContainerFormatClassifiesAsAPlayableDiscImage(string path, string hint)
{
var classification = ArtifactAnalysis.Classify(path, [], hint);
Assert.Equal(MediaType.DiscImage, classification.MediaType);
Assert.Equal(hint, classification.Platform);
Assert.True(classification.Supported);
}
[Fact]
public void EveryFormatTheDolphinPolicyAcceptsIsIdentifiable()
{
foreach (var extension in NativeRemotePlayerRegistry.Dolphin.Extensions)
{
var classification = ArtifactAnalysis.Classify($"roms/wii/Game{extension}", [], "wii");
Assert.True(classification.Supported,
$"{extension} is launchable in Dolphin but is not identified by the scanner.");
}
}
[Fact]
public void MegaDriveCartridgesAreIdentifiedAndSeparatedFromTheirVariants()
{
var genesis = ArtifactAnalysis.Classify("roms/unsorted/Game.md", SegaCartridge("SEGA MEGA DRIVE "));
var thirtyTwoX = ArtifactAnalysis.Classify("roms/unsorted/Game.32x", SegaCartridge("SEGA 32X "));
var segaCd = ArtifactAnalysis.Classify("roms/unsorted/Game.bin", SegaCartridge("SEGA SEGACD "));
Assert.Equal("genesis", genesis.Platform);
Assert.Equal(MediaType.Rom, genesis.MediaType);
Assert.Equal("sega32x", thirtyTwoX.Platform);
Assert.Equal("segacd", segaCd.Platform);
Assert.Contains(genesis.Evidence, item => item.Kind == "rom.internationalTitle" &&
item.Value == "LUDARIUM GENESIS FIXTURE");
}
[Theory]
[InlineData(0x40, "mastersystem")]
[InlineData(0x30, "mastersystem")]
[InlineData(0x50, "gamegear")]
[InlineData(0x60, "gamegear")]
[InlineData(0x70, "gamegear")]
public void TheEightBitSegaRegionNibbleSeparatesMasterSystemFromGameGear(int region, string expected)
{
var classification = ArtifactAnalysis.Classify("roms/unsorted/Game.sms", SegaEightBit(region));
Assert.Equal(expected, classification.Platform);
Assert.Equal(MediaType.Rom, classification.MediaType);
Assert.Equal(Confidence.Deterministic, classification.Confidence);
Assert.Contains(classification.Evidence, item => item.Kind == "rom.productCode" && item.Value == "50123");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.revision" && item.Value == "1");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.checksum" && item.Value == "1234");
}
[Theory]
[InlineData("megadrive", "genesis")]
[InlineData("mega-drive", "genesis")]
[InlineData("MD", "genesis")]
[InlineData("sms", "mastersystem")]
[InlineData("game-gear", "gamegear")]
public void HardwareNamedDirectoriesResolveToTheCanonicalPlatform(string directory, string expected)
{
var hint = ArtifactAnalysis.InferPlatformHint("library", "/mnt/games", $"roms/{directory}/Game.md");
Assert.Equal(expected, hint);
Assert.Equal(expected, ArtifactAnalysis.Classify($"roms/{directory}/Game.md", [], hint).Platform);
}
[Fact]
public void PlatformValuesWrittenByEarlierVersionsStillResolve()
{
Assert.Equal("genesis", ArtifactAnalysis.CanonicalPlatform("sega-genesis"));
Assert.Equal("mastersystem", ArtifactAnalysis.CanonicalPlatform("sms"));
Assert.Equal("nes", ArtifactAnalysis.CanonicalPlatform("nes"));
Assert.Null(ArtifactAnalysis.CanonicalPlatform(null));
}
[Fact]
public void ARawTrackIsOnlyADiscImageInsideADiscLibrary()
{
var disc = ArtifactAnalysis.Classify("roms/psx/Game/Game (Track 1).bin", [], "psx");
var installer = ArtifactAnalysis.Classify("PC/Some Game/data1.bin", [], "windows");
Assert.Equal(MediaType.DiscImage, disc.MediaType);
Assert.Equal("psx", disc.Platform);
// An installer payload must not be promoted to a disc image on the strength of its extension.
Assert.Equal(MediaType.Unknown, installer.MediaType);
}
[Theory]
[InlineData("roms/3ds/Game.3ds", "3ds")]
[InlineData("roms/3ds/Game.cia", "3ds")]
[InlineData("roms/wiiu/Game.wud", "wiiu")]
[InlineData("roms/psvita/Game.vpk", "psvita")]
public void FormatsForResearchedPlatformsAreIdentifiedEvenWithoutAPlayer(string path, string platform)
{
var classification = ArtifactAnalysis.Classify(path, []);
Assert.Equal(platform, classification.Platform);
Assert.True(classification.Supported);
}
private static byte[] GameCubeDisc()
{
var header = new byte[0x500];
Encoding.ASCII.GetBytes("GLZE").CopyTo(header, 0);
Encoding.ASCII.GetBytes("01").CopyTo(header, 4);
header[6] = 0;
header[7] = 1;
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(0x1C), 0xC2339F3D);
Encoding.ASCII.GetBytes("LUDARIUM GAMECUBE FIXTURE").CopyTo(header, 0x20);
return header;
}
private static byte[] WiiDisc()
{
var header = new byte[0x500];
Encoding.ASCII.GetBytes("RLZP").CopyTo(header, 0);
Encoding.ASCII.GetBytes("01").CopyTo(header, 4);
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(0x18), 0x5D1C9EA3);
Encoding.ASCII.GetBytes("LUDARIUM WII FIXTURE").CopyTo(header, 0x20);
return header;
}
private static byte[] SegaCartridge(string console)
{
var rom = new byte[0x200];
Encoding.ASCII.GetBytes(console.PadRight(16)[..16]).CopyTo(rom, 0x100);
Encoding.ASCII.GetBytes("(C)LUDARIUM 2026".PadRight(16)).CopyTo(rom, 0x110);
Encoding.ASCII.GetBytes("LUDARIUM GENESIS FIXTURE".PadRight(48)).CopyTo(rom, 0x120);
Encoding.ASCII.GetBytes("LUDARIUM GENESIS FIXTURE".PadRight(48)).CopyTo(rom, 0x150);
Encoding.ASCII.GetBytes("GM 00000000-00".PadRight(14)).CopyTo(rom, 0x180);
Encoding.ASCII.GetBytes("JUE").CopyTo(rom, 0x1F0);
return rom;
}
private static byte[] SegaEightBit(int region)
{
var rom = new byte[0x8000];
Encoding.ASCII.GetBytes("TMR SEGA").CopyTo(rom, 0x7FF0);
rom[0x7FFA] = 0x34; // checksum, low byte
rom[0x7FFB] = 0x12; // checksum, high byte
rom[0x7FFC] = 0x23; // product code, low BCD pair
rom[0x7FFD] = 0x01; // product code, high BCD pair
rom[0x7FFE] = 0x51; // top product digit and revision 1
rom[0x7FFF] = (byte)(region | 0x0C); // region nibble plus a 32 KiB size nibble
return rom;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Diagnostics;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class ScaleTests
{
[Theory]
[InlineData(10_000)]
[InlineData(100_000)]
[Trait("Category", "Scale")]
public void ClassifierProcessesLargeSyntheticInventoriesWithinBoundedResources(int count)
{
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
var stopwatch = Stopwatch.StartNew();
var recognized = 0;
for (var index = 0; index < count; index++)
{
var platform = index % 2 == 0 ? "nes" : "gba";
var extension = platform == "nes" ? "nes" : "gba";
ReadOnlySpan<byte> header = platform == "nes"
? new byte[] { 0x4e, 0x45, 0x53, 0x1a }
: new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 };
var result = ArtifactAnalysis.Classify($"roms/{platform}/Synthetic-{index:D6}.{extension}",
header, platform);
if (result.MediaType == MediaType.Rom && result.Platform == platform) recognized++;
}
stopwatch.Stop();
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
Assert.Equal(count, recognized);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(30), $"Classification took {stopwatch.Elapsed}.");
Assert.True(allocated < 512L * 1024 * 1024, $"Classification allocated {allocated:N0} bytes.");
}
}
@@ -0,0 +1,34 @@
using System.Collections.ObjectModel;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class SnapshotComparerTests
{
[Fact]
public void ComparisonDistinguishesAddedRemovedMovedChangedAndUnchanged()
{
var root = Guid.NewGuid();
var moved = Guid.NewGuid(); var changed = Guid.NewGuid(); var removed = Guid.NewGuid(); var unchanged = Guid.NewGuid(); var added = Guid.NewGuid();
var before = Snapshot("before",
new(root, "old/game.bin", 1, Hash('a'), moved, null),
new(root, "changed.bin", 1, Hash('b'), changed, null),
new(root, "removed.bin", 1, Hash('c'), removed, null),
new(root, "same.bin", 1, Hash('d'), unchanged, null));
var after = Snapshot("after",
new(root, "new/game.bin", 1, Hash('a'), moved, null),
new(root, "changed.bin", 2, Hash('e'), changed, null),
new(root, "same.bin", 1, Hash('d'), unchanged, null),
new(root, "added.bin", 1, Hash('f'), added, null));
var result = new SnapshotComparer().Compare(before, after);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Added);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Removed);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Moved);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.ContentChanged);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Unchanged);
}
private static IntegritySnapshot Snapshot(string name, params SnapshotItem[] items) => new(Guid.NewGuid(), name, "1", "test", DateTimeOffset.UtcNow, new ReadOnlyCollection<SnapshotItem>(items));
private static string Hash(char value) => new(value, 64);
}
@@ -0,0 +1,20 @@
using System.Text.Json;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class SnapshotSerializationTests
{
[Fact]
public void PersistedSnapshotRoundTripsThroughJson()
{
var expected = new IntegritySnapshot(Guid.NewGuid(), "baseline", "6", "0.3.1", DateTimeOffset.UtcNow,
[new SnapshotItem(Guid.NewGuid(), "roms/nes/example.nes", 16, new string('a', 64), Guid.NewGuid(), null)]);
var actual = JsonSerializer.Deserialize<IntegritySnapshot>(JsonSerializer.Serialize(expected));
Assert.NotNull(actual);
Assert.Single(actual.Items);
Assert.Equal(expected.Items[0].RelativePath, actual.Items[0].RelativePath);
}
}
@@ -0,0 +1,77 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class SwitchLaunchPolicyTests
{
[Fact]
public void SelectsUniqueReadOnlyBaseXciAndRejectsTicketUpdateAndDlc()
{
var candidates = new[]
{
Candidate("Animal Crossing ticket [01006F800232712C][v0].nsp", 124_112),
Candidate("Animal Crossing update [01006F8002326800][v1769472].nsp", 4_332_291_043),
Candidate("Happy Home Paradise [01006F80023273E8][v0].nsp", 624_384_208),
Candidate("Animal Crossing New Horizons [01006F8002326000][v0].xci", 7_985_954_816),
};
Assert.EndsWith(".xci", SwitchLaunchPolicy.SelectBaseGame(candidates)!.RelativePath);
}
[Fact]
public void FailsClosedWhenTheBaseGameIsAmbiguousOrWritable()
{
var first = Candidate("Game A [0100000000000000][v0].xci", 2_000_000);
var second = Candidate("Game B [0100000000010000][v0].xci", 3_000_000);
Assert.Null(SwitchLaunchPolicy.SelectBaseGame([first, second]));
Assert.Null(SwitchLaunchPolicy.SelectBaseGame([first with { SourceReadOnly = false }]));
}
[Fact]
public void MapsOnlyContainedCatalogPaths()
{
Assert.Equal("Animal/Game [0100000000000000][v0].xci",
SwitchLaunchPolicy.ToRuntimePath("roms/switch/Animal/Game [0100000000000000][v0].xci", "roms/switch"));
Assert.Throws<InvalidOperationException>(() => SwitchLaunchPolicy.ToRuntimePath("roms/ps5/game.xci", "roms/switch"));
}
[Fact]
public void CapabilityIsAvailableOnlyForOneQualifiedBaseGame()
{
var gameId = Guid.NewGuid();
var available = SwitchLaunchPolicy.EvaluateCapability(gameId, true, true, true,
[Candidate("Game [0100000000000000][v0].xci", 2_000_000)]);
var ambiguous = SwitchLaunchPolicy.EvaluateCapability(gameId, true, true, true,
[Candidate("Game A [0100000000000000][v0].xci", 2_000_000), Candidate("Game B [0100000000010000][v0].xci", 3_000_000)]);
Assert.True(available.Available);
Assert.Equal(NativeRemotePlayState.Available, available.State);
Assert.False(ambiguous.Available);
Assert.Equal(NativeRemotePlayState.AmbiguousMapping, ambiguous.State);
}
[Theory]
[InlineData(false, true, true, NativeRemotePlayState.UnsupportedPlatform)]
[InlineData(true, false, true, NativeRemotePlayState.RuntimeUnavailable)]
[InlineData(true, true, false, NativeRemotePlayState.MissingKeys)]
public void CapabilityExplainsGlobalPrerequisites(bool isSwitch, bool runtime, bool keys,
NativeRemotePlayState expected)
{
var capability = SwitchLaunchPolicy.EvaluateCapability(Guid.NewGuid(), isSwitch, runtime, keys, []);
Assert.False(capability.Available);
Assert.Equal(expected, capability.State);
}
[Fact]
public void CapabilityDistinguishesAnUpdateWithoutItsBaseGameFromAmbiguity()
{
var capability = SwitchLaunchPolicy.EvaluateCapability(Guid.NewGuid(), true, true, true,
[Candidate("Game update [0100000000000800][v131072].nsp", 4_000_000)]);
Assert.False(capability.Available);
Assert.Equal(NativeRemotePlayState.MissingBaseGame, capability.State);
Assert.Contains("updates and DLC cannot start alone", capability.Message);
}
private static NativeLaunchCandidate Candidate(string path, long size) => new(Guid.NewGuid(), "roms/switch/" + path, size, true);
}
+119
View File
@@ -0,0 +1,119 @@
{
"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.CodeCoverage": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
},
"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"
}
},
"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]"
}
},
"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"
}
}
}
}
+609
View File
@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""Deterministic tests for the emulator sidecar control surface.
`resolve_game_path` is the last defence between an authenticated control request and a
process launch inside the sidecar, and `desktop_display` decides whether a launch reaches
the live desktop at all. Both are covered here so a future sidecar (Azahar, xemu, Cemu,
Vita3K) inherits proven behaviour instead of a copied one.
Run with: python -m unittest discover -s tests/controllers -t .
"""
import json
import os
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest import mock
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
sys.path.insert(0, str(DEPLOY))
import ludarium_sidecar as sidecar # noqa: E402
def load_controller(module_name, file_name, token_variable, token="test-token"):
"""Import a controller script under a controlled games root and control token."""
import importlib.util
root = tempfile.mkdtemp(prefix=f"ludarium-{module_name}-")
environment = {token_variable: token, "LUDARIUM_GAMES_ROOT": root}
with mock.patch.dict(os.environ, environment, clear=False):
spec = importlib.util.spec_from_file_location(module_name, DEPLOY / file_name)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module, Path(root)
class ResolveGamePathTests(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="ludarium-games-"))
(self.root / "gamecube").mkdir()
self.game = self.root / "gamecube" / "title.iso"
self.game.write_bytes(b"\0" * 16)
self.extensions = {".iso", ".rvz"}
self.platforms = {"gamecube", "wii"}
def resolve(self, value, platforms=None):
return sidecar.resolve_game_path(value, self.root, self.extensions, platforms)
def test_accepts_an_allowlisted_file_inside_the_mount(self):
self.assertEqual(self.resolve("gamecube/title.iso", self.platforms), self.game.resolve())
def test_rejects_an_absolute_path(self):
with self.assertRaises(ValueError):
self.resolve("/etc/passwd")
def test_rejects_an_empty_path(self):
with self.assertRaises(ValueError):
self.resolve("")
def test_rejects_parent_traversal(self):
with self.assertRaises(ValueError):
self.resolve("gamecube/../../etc/passwd", self.platforms)
def test_normalises_a_current_directory_segment(self):
# PurePosixPath drops "." segments, so the path still resolves inside the mount.
self.assertEqual(self.resolve("gamecube/./title.iso", self.platforms), self.game.resolve())
def test_rejects_a_leading_parent_segment(self):
with self.assertRaises(ValueError):
self.resolve("../gamecube/title.iso", self.platforms)
def test_rejects_an_unlisted_platform_directory(self):
with self.assertRaises(ValueError):
self.resolve("switch/title.iso", self.platforms)
def test_rejects_a_bare_file_when_a_platform_directory_is_required(self):
with self.assertRaises(ValueError):
self.resolve("title.iso", self.platforms)
def test_rejects_a_disallowed_extension(self):
(self.root / "gamecube" / "notes.txt").write_text("x")
with self.assertRaises(ValueError):
self.resolve("gamecube/notes.txt", self.platforms)
def test_extension_matching_is_case_insensitive(self):
upper = self.root / "gamecube" / "Upper.ISO"
upper.write_bytes(b"\0" * 16)
self.assertEqual(self.resolve("gamecube/Upper.ISO", self.platforms), upper.resolve())
def test_rejects_a_missing_file(self):
with self.assertRaises(ValueError):
self.resolve("gamecube/absent.iso", self.platforms)
def test_rejects_a_directory(self):
(self.root / "gamecube" / "disc.iso").mkdir()
with self.assertRaises(ValueError):
self.resolve("gamecube/disc.iso", self.platforms)
@unittest.skipUnless(hasattr(os, "symlink") and sys.platform != "win32",
"symlink creation needs privileges on Windows")
def test_rejects_a_symlink_that_escapes_the_mount(self):
outside = Path(tempfile.mkdtemp(prefix="ludarium-outside-")) / "secret.iso"
outside.write_bytes(b"\0" * 16)
os.symlink(outside, self.root / "gamecube" / "link.iso")
with self.assertRaises(ValueError):
self.resolve("gamecube/link.iso", self.platforms)
def test_accepts_a_bare_file_when_no_platform_directory_is_required(self):
game = self.root / "title.rvz"
game.write_bytes(b"\0" * 16)
self.assertEqual(self.resolve("title.rvz"), game.resolve())
class DesktopDisplayTests(unittest.TestCase):
"""The live Eden defect: after a restart the newest socket is not the active display."""
def test_prefers_the_display_published_by_the_running_process(self):
def run(argv, **kwargs):
if argv[0].endswith("pgrep"):
return mock.Mock(returncode=0, stdout="4242")
return mock.Mock(returncode=0, stdout=b"HOME=/config\0DISPLAY=:7\0LANG=C\0")
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
self.assertEqual(sidecar.desktop_display("eden"), ":7")
def test_falls_back_to_the_newest_socket_when_no_process_is_running(self):
with mock.patch.object(sidecar.subprocess, "run",
return_value=mock.Mock(returncode=1, stdout="")), \
mock.patch.object(sidecar.Path, "glob", autospec=True) as glob:
sockets = []
for number, mtime in ((0, 100), (9, 50)):
socket = mock.Mock()
socket.name = f"X{number}"
socket.stat.return_value = mock.Mock(st_mtime_ns=mtime)
sockets.append(socket)
glob.return_value = sockets
# The newest socket wins over the highest number, matching the live fallback.
self.assertEqual(sidecar.desktop_display("eden"), ":0")
def test_falls_back_to_the_environment_when_nothing_is_discoverable(self):
with mock.patch.object(sidecar.subprocess, "run",
return_value=mock.Mock(returncode=1, stdout="")), \
mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \
mock.patch.dict(os.environ, {"DISPLAY": ":3"}, clear=False):
self.assertEqual(sidecar.desktop_display("eden"), ":3")
def test_ignores_a_malformed_published_display(self):
def run(argv, **kwargs):
if argv[0].endswith("pgrep"):
return mock.Mock(returncode=0, stdout="4242")
return mock.Mock(returncode=0, stdout=b"DISPLAY=:not-a-number\0")
with mock.patch.object(sidecar.subprocess, "run", side_effect=run), \
mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \
mock.patch.dict(os.environ, {"DISPLAY": ":1"}, clear=False):
self.assertEqual(sidecar.desktop_display("eden"), ":1")
def test_survives_a_failing_process_lookup(self):
with mock.patch.object(sidecar.subprocess, "run", side_effect=OSError("no pgrep")), \
mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \
mock.patch.dict(os.environ, {"DISPLAY": ":2"}, clear=False):
self.assertEqual(sidecar.desktop_display("eden"), ":2")
class VisibleWindowTests(unittest.TestCase):
def test_prefers_the_launched_process_id(self):
calls = []
def run(argv, **kwargs):
calls.append(argv)
return mock.Mock(returncode=0, stdout="123\n")
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), ["123"])
self.assertEqual(len(calls), 1)
self.assertIn("--pid", calls[0])
def test_falls_back_to_the_window_name(self):
def run(argv, **kwargs):
return mock.Mock(returncode=0, stdout="" if "--pid" in argv else "456\n")
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), ["456"])
def test_returns_nothing_when_no_window_matches(self):
with mock.patch.object(sidecar.subprocess, "run",
return_value=mock.Mock(returncode=1, stdout="")):
self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), [])
def build_runtime(root, **overrides):
options = dict(
name="dolphin", process_name="dolphin-emu",
launch_argv=lambda target: ["/usr/bin/dolphin-emu", "--batch", "--exec", str(target)],
games=root, extensions={".iso"}, hotkeys={"pause-resume": "F10"},
save_mode="native-and-savestate-persistent", platforms={"gamecube"},
window_name="Dolphin", ready_seconds=0, save_paths=())
options.update(overrides)
return sidecar.EmulatorRuntime(**options)
class EmulatorRuntimeTests(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="ludarium-runtime-"))
(self.root / "gamecube").mkdir()
(self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16)
self.runtime = build_runtime(self.root)
self.environment = mock.patch.object(
sidecar, "runtime_environment", return_value={"DISPLAY": ":1"})
self.environment.start()
self.addCleanup(self.environment.stop)
def test_reports_no_running_title_before_a_launch(self):
self.assertEqual(self.runtime.status(),
{"running": False, "pid": None, "saveRoot": None, "savePaths": [],
"saveMode": "native-and-savestate-persistent", "saveData": False})
def test_launch_returns_the_process_id_and_reports_running(self):
process = mock.Mock(pid=4321)
process.poll.return_value = None
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process) as popen:
self.assertEqual(self.runtime.launch(self.root / "gamecube" / "title.iso"), 4321)
argv = popen.call_args.args[0]
self.assertEqual(argv[:2], [sidecar.SETUIDGID, sidecar.RUNTIME_USER])
self.assertIn("--batch", argv)
self.assertTrue(self.runtime.status()["running"])
def test_launch_reports_an_early_exit(self):
process = mock.Mock(pid=4321, returncode=1)
process.poll.return_value = 1
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
self.assertIsNone(self.runtime.launch(self.root / "gamecube" / "title.iso"))
def test_launch_terminates_a_previously_running_title(self):
first = mock.Mock(pid=1)
first.poll.return_value = None
second = mock.Mock(pid=2)
second.poll.return_value = None
with mock.patch.object(sidecar.subprocess, "Popen", side_effect=[first, second]):
self.runtime.launch(self.root / "gamecube" / "title.iso")
self.runtime.launch(self.root / "gamecube" / "title.iso")
first.send_signal.assert_called_once_with(sidecar.signal.SIGTERM)
def test_stop_reports_false_when_nothing_runs(self):
self.assertFalse(self.runtime.stop())
def test_stop_kills_a_title_that_ignores_termination(self):
process = mock.Mock(pid=7)
process.poll.return_value = None
process.wait.side_effect = [sidecar.subprocess.TimeoutExpired("dolphin-emu", 8), 0]
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
self.runtime.launch(self.root / "gamecube" / "title.iso")
self.assertTrue(self.runtime.stop())
process.kill.assert_called_once()
self.assertFalse(self.runtime.status()["running"])
def test_hotkey_targets_the_launched_process(self):
process = mock.Mock(pid=555)
process.poll.return_value = None
sent = []
def run(argv, **kwargs):
sent.append(argv)
return mock.Mock(returncode=0, stdout="900\n")
with mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
self.runtime.launch(self.root / "gamecube" / "title.iso")
with mock.patch.object(sidecar.subprocess, "run", side_effect=run):
self.assertTrue(self.runtime.hotkey("F10"))
self.assertIn("555", sent[0])
self.assertEqual(sent[-1][-3:], ["900", "key", "F10"])
def test_hotkey_reports_false_without_a_window(self):
with mock.patch.object(sidecar.subprocess, "run",
return_value=mock.Mock(returncode=1, stdout="")):
self.assertFalse(self.runtime.hotkey("F10"))
class ControlSurfaceTests(unittest.TestCase):
"""Covers the exact status codes the Ludarium API depends on."""
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="ludarium-surface-"))
(self.root / "gamecube").mkdir()
(self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16)
self.runtime = build_runtime(self.root)
self.server = ThreadingHTTPServer(
("127.0.0.1", 0), sidecar.build_handler(self.runtime, "secret-token"))
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
def call(self, path, method="GET", body=None, token="secret-token", raw=None):
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
request = urllib.request.Request(self.base + path, data=payload, method=method)
if token:
request.add_header("X-Ludarium-Control-Token", token)
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read() or b"{}")
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read() or b"{}")
def test_health_needs_no_token(self):
self.assertEqual(self.call("/health", token=None), (200, {"status": "healthy"}))
def test_status_needs_a_token(self):
self.assertEqual(self.call("/v1/status", token=None)[0], 404)
self.assertEqual(self.call("/v1/status", token="wrong")[0], 404)
def test_status_reports_the_save_mode(self):
status, body = self.call("/v1/status")
self.assertEqual(status, 200)
self.assertEqual(body, {"running": False, "pid": None, "saveRoot": None, "savePaths": [],
"saveMode": "native-and-savestate-persistent", "saveData": False})
def test_unknown_get_route_is_not_found(self):
self.assertEqual(self.call("/v1/anything")[0], 404)
def test_unauthenticated_post_is_not_found(self):
self.assertEqual(self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"},
token=None)[0], 404)
def test_launch_accepts_an_allowlisted_game(self):
process = mock.Mock(pid=31337)
process.poll.return_value = None
with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \
mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
status, body = self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"})
self.assertEqual(status, 202)
self.assertEqual(body["pid"], 31337)
self.assertEqual(body["saveMode"], "native-and-savestate-persistent")
def test_launch_rejects_traversal(self):
status, body = self.call("/v1/launch", "POST", {"path": "gamecube/../../etc/passwd"})
self.assertEqual(status, 400)
self.assertIn("Unsafe", body["message"])
def test_launch_reports_an_early_exit_as_unavailable(self):
process = mock.Mock(pid=1, returncode=3)
process.poll.return_value = 3
with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \
mock.patch.object(sidecar.subprocess, "Popen", return_value=process):
self.assertEqual(self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"})[0], 503)
def test_oversized_body_is_rejected(self):
self.assertEqual(self.call("/v1/launch", "POST", raw=b"{" + b"a" * 9000)[0], 400)
def test_empty_body_is_rejected(self):
self.assertEqual(self.call("/v1/launch", "POST", raw=b"")[0], 400)
def test_non_object_body_is_rejected(self):
self.assertEqual(self.call("/v1/action", "POST", raw=b'"stop"')[0], 400)
def test_unsupported_action_is_rejected(self):
self.assertEqual(self.call("/v1/action", "POST", {"action": "self-destruct"})[0], 400)
def test_stop_without_a_running_title_conflicts(self):
self.assertEqual(self.call("/v1/action", "POST", {"action": "stop"})[0], 409)
def test_hotkey_without_a_window_conflicts(self):
with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \
mock.patch.object(sidecar.subprocess, "run",
return_value=mock.Mock(returncode=1, stdout="")):
self.assertEqual(self.call("/v1/action", "POST", {"action": "pause-resume"})[0], 409)
class ControllerIdentityTests(unittest.TestCase):
"""Each shipped controller must keep its exact runtime contract."""
def test_dolphin_controller(self):
module, root = load_controller("dolphin_controller", "dolphin-controller.py",
"LUDARIUM_DOLPHIN_CONTROL_TOKEN")
runtime = module.RUNTIME
# Dolphin's profile also holds a shader cache; only save-bearing directories are captured.
self.assertEqual(runtime.save_paths, ("GC", "Wii", "StateSaves"))
self.assertEqual(runtime.name, "dolphin")
self.assertEqual(runtime.process_name, "dolphin-emu")
self.assertEqual(runtime.platforms, {"gamecube", "wii"})
self.assertEqual(runtime.extensions,
{".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"})
self.assertEqual(set(runtime.hotkeys),
{"pause-resume", "fullscreen", "save-state", "load-state"})
self.assertEqual(runtime.save_mode, "native-and-savestate-persistent")
self.assertEqual(runtime.launch_argv("/games/wii/a.iso"),
["/usr/bin/dolphin-emu", "--batch", "--exec", "/games/wii/a.iso"])
self.assertEqual(Path(runtime.games), root)
def test_eden_controller(self):
module, root = load_controller("eden_controller", "eden-controller.py",
"LUDARIUM_EDEN_CONTROL_TOKEN")
runtime = module.RUNTIME
self.assertEqual(runtime.save_paths, ("nand/user/save",))
self.assertEqual(runtime.name, "eden")
self.assertEqual(runtime.process_name, "eden")
self.assertIsNone(runtime.platforms)
self.assertEqual(runtime.extensions, {".xci", ".nsp"})
self.assertEqual(set(runtime.hotkeys), {"pause-resume", "fullscreen"})
self.assertEqual(runtime.save_mode, "native-persistent")
self.assertEqual(runtime.launch_argv("/games/a.xci"),
["/usr/bin/eden", "-f", "-g", "/games/a.xci"])
self.assertEqual(Path(runtime.games), root)
def test_a_controller_refuses_to_serve_without_a_token(self):
module, _ = load_controller("eden_untokened", "eden-controller.py",
"LUDARIUM_EDEN_CONTROL_TOKEN", token="")
with self.assertRaises(SystemExit):
module.main()
if __name__ == "__main__":
unittest.main()
class SaveDataTests(unittest.TestCase):
"""Round-tripping an emulator's app-owned save directories through the vault."""
SAVE_PATHS = ("nand/user/save", "StateSaves")
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="ludarium-saves-"))
(self.root / "nand" / "user" / "save").mkdir(parents=True)
(self.root / "nand" / "user" / "save" / "save.bin").write_bytes(b"progress" * 64)
(self.root / "StateSaves").mkdir()
(self.root / "StateSaves" / "slot1.sav").write_bytes(b"state")
# A profile also holds material a capture must never carry: on a real Dolphin install this
# is a shader cache far larger than one bounded revision.
(self.root / "Cache").mkdir()
(self.root / "Cache" / "shaders.bin").write_bytes(b"x" * 4096)
(self.root / "config.ini").write_text("[General]\n")
def export(self):
return sidecar.export_save_data(self.root, self.SAVE_PATHS)
def test_export_packs_only_the_configured_save_directories(self):
import io
import tarfile
with tarfile.open(fileobj=io.BytesIO(self.export()), mode="r:gz") as archive:
names = sorted(member.name for member in archive)
self.assertEqual(names, ["StateSaves/slot1.sav", "nand/user/save/save.bin"])
self.assertTrue(all(member.mode == 0o644 and member.uid == 0 for member in archive))
def test_export_packs_the_whole_root_when_no_paths_are_configured(self):
import io
import tarfile
with tarfile.open(fileobj=io.BytesIO(sidecar.export_save_data(self.root)), mode="r:gz") as archive:
self.assertIn("Cache/shaders.bin", [member.name for member in archive])
def test_export_ignores_a_configured_path_that_does_not_exist(self):
import io
import tarfile
payload = sidecar.export_save_data(self.root, ("StateSaves", "GC", "Wii"))
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
self.assertEqual([member.name for member in archive], ["StateSaves/slot1.sav"])
def test_export_rejects_a_missing_directory(self):
with self.assertRaises(ValueError):
sidecar.export_save_data(self.root / "absent")
def test_export_rejects_a_profile_with_no_save_directories(self):
with self.assertRaises(ValueError):
sidecar.export_save_data(self.root, ("GC", "Wii"))
def test_export_rejects_an_escaping_configured_path(self):
with self.assertRaises(ValueError):
sidecar.export_save_data(self.root, ("../..",))
def test_a_round_trip_restores_the_exact_content(self):
payload = self.export()
(self.root / "nand" / "user" / "save" / "save.bin").write_bytes(b"overwritten")
(self.root / "StateSaves" / "slot1.sav").unlink()
self.assertEqual(sidecar.import_save_data(self.root, payload, self.SAVE_PATHS), 2)
self.assertEqual((self.root / "nand" / "user" / "save" / "save.bin").read_bytes(), b"progress" * 64)
self.assertEqual((self.root / "StateSaves" / "slot1.sav").read_bytes(), b"state")
def test_a_restore_leaves_unrelated_profile_content_alone(self):
payload = self.export()
sidecar.import_save_data(self.root, payload, self.SAVE_PATHS)
self.assertEqual((self.root / "config.ini").read_text(), "[General]\n")
self.assertEqual((self.root / "Cache" / "shaders.bin").stat().st_size, 4096)
def test_restore_rejects_a_member_outside_the_save_directories(self):
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
info = tarfile.TarInfo("config.ini")
info.size = 5
archive.addfile(info, io.BytesIO(b"evil!"))
# A stored revision must never be able to reach an emulator's configuration.
with self.assertRaises(ValueError):
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
self.assertEqual((self.root / "config.ini").read_text(), "[General]\n")
def test_restore_rejects_an_archive_that_escapes_the_save_directory(self):
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
info = tarfile.TarInfo("../escaped.bin")
info.size = 4
archive.addfile(info, io.BytesIO(b"evil"))
with self.assertRaises(ValueError):
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
self.assertFalse((self.root.parent / "escaped.bin").exists())
def test_restore_rejects_a_symlink_member(self):
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
info = tarfile.TarInfo("link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
archive.addfile(info)
with self.assertRaises(ValueError):
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
def test_restore_rejects_an_oversized_archive(self):
with self.assertRaises(ValueError):
sidecar.import_save_data(self.root, b"\0" * (sidecar.MAX_SAVE_DATA_BYTES + 1), self.SAVE_PATHS)
def test_restore_prevalidates_the_expanded_size_before_writing(self):
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
info = tarfile.TarInfo("save/large.bin")
info.size = 5
archive.addfile(info, io.BytesIO(b"12345"))
with mock.patch.object(sidecar, "MAX_SAVE_DATA_EXPANDED_BYTES", 4), self.assertRaises(ValueError):
sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS)
self.assertFalse((self.root / "save" / "large.bin").exists())
def test_status_reports_save_data_support_and_the_exact_directories(self):
without = build_runtime(self.root)
with_saves = build_runtime(self.root, save_root=str(self.root), save_paths=self.SAVE_PATHS)
self.assertFalse(without.status()["saveData"])
status = with_saves.status()
self.assertTrue(status["saveData"])
self.assertEqual(status["saveRoot"], str(self.root))
# A misconfigured path is visible here instead of surfacing at capture time.
self.assertEqual(sorted(Path(path).name for path in status["savePaths"]),
["StateSaves", "save"])
self.assertFalse(build_runtime(self.root, save_root=str(self.root / "absent")).status()["saveData"])
self.assertFalse(build_runtime(self.root, save_root=str(self.root),
save_paths=("GC",)).status()["saveData"])
class SaveDataSurfaceTests(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="ludarium-savesurface-"))
(self.root / "gamecube").mkdir()
(self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16)
self.saves = Path(tempfile.mkdtemp(prefix="ludarium-saveroot-"))
(self.saves / "GC").mkdir()
(self.saves / "GC" / "card.raw").write_bytes(b"memory-card")
self.runtime = build_runtime(self.root, save_root=str(self.saves), save_paths=("GC",))
self.server = ThreadingHTTPServer(
("127.0.0.1", 0), sidecar.build_handler(self.runtime, "secret-token"))
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
def request(self, method, data=None, token="secret-token"):
request = urllib.request.Request(self.base + "/v1/save-data", data=data, method=method)
if token:
request.add_header("X-Ludarium-Control-Token", token)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
return error.code, error.read()
def test_export_needs_a_token(self):
self.assertEqual(self.request("GET", token=None)[0], 404)
def test_export_and_restore_round_trip_over_http(self):
status, payload = self.request("GET")
self.assertEqual(status, 200)
(self.saves / "GC" / "card.raw").write_bytes(b"lost")
self.assertEqual(self.request("POST", payload)[0], 202)
self.assertEqual((self.saves / "GC" / "card.raw").read_bytes(), b"memory-card")
def test_restore_rejects_a_non_archive(self):
self.assertEqual(self.request("POST", b"not-an-archive")[0], 400)
def test_restore_rejects_an_empty_body(self):
self.assertEqual(self.request("POST", b"")[0], 400)
def test_a_runtime_without_a_save_directory_conflicts(self):
runtime = build_runtime(self.root)
server = ThreadingHTTPServer(("127.0.0.1", 0), sidecar.build_handler(runtime, "secret-token"))
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
request = urllib.request.Request(
f"http://127.0.0.1:{server.server_address[1]}/v1/save-data", method="GET")
request.add_header("X-Ludarium-Control-Token", "secret-token")
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(request, timeout=5)
self.assertEqual(error.exception.code, 409)