This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Ludarium.Api\Ludarium.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.Net;
|
||||
using Ludarium.Api;
|
||||
using Ludarium.Application;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// The shared transport behind every isolated native remote player. These assertions previously
|
||||
/// existed once per emulator; keeping them here is what guarantees a new player inherits the same
|
||||
/// pinned TLS, opaque capability, cookie scope and revocation behaviour.
|
||||
/// </summary>
|
||||
public sealed class NativeRemotePlayerTests
|
||||
{
|
||||
private const string CertificateHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
private static NativeRemotePlayer Switch => NativeRemotePlayerRegistry.Switch;
|
||||
private static NativeRemotePlayer Dolphin => NativeRemotePlayerRegistry.Dolphin;
|
||||
|
||||
[Theory]
|
||||
[InlineData("switch", "eden", "/switch-player", "ludarium-switch-session", 1231)]
|
||||
[InlineData("dolphin", "dolphin", "/dolphin-player", "ludarium-dolphin-session", 1232)]
|
||||
public void RegistryDrivesEveryPublicIdentifier(string key, string host, string proxyPath,
|
||||
string cookie, int port)
|
||||
{
|
||||
var player = NativeRemotePlayerRegistry.ForKey(key)!;
|
||||
Assert.Equal(host, player.ProxyHost);
|
||||
Assert.Equal(proxyPath, player.ProxyPath);
|
||||
Assert.Equal(cookie, player.CookieName);
|
||||
Assert.Equal(port, player.DefaultPublicPort);
|
||||
Assert.Equal($"{key}-player-sessions", player.SessionRoute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryNativePlatformResolvesToExactlyOnePlayer()
|
||||
{
|
||||
Assert.Same(Switch, NativeRemotePlayerRegistry.ForPlatform("switch"));
|
||||
Assert.Same(Dolphin, NativeRemotePlayerRegistry.ForPlatform("GameCube"));
|
||||
Assert.Same(Dolphin, NativeRemotePlayerRegistry.ForPlatform("wii"));
|
||||
Assert.Null(NativeRemotePlayerRegistry.ForPlatform("ps2"));
|
||||
var owners = NativeRemotePlayerRegistry.Platforms
|
||||
.Select(platform => NativeRemotePlayerRegistry.All.Count(player => player.Owns(platform)));
|
||||
Assert.All(owners, count => Assert.Equal(1, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AGameNamingTwoNativePlatformsIsDeliberatelyUnresolvable()
|
||||
{
|
||||
Assert.Equal("wii", NativeRemotePlayerRegistry.ResolvePlatform(["wii", "wii", null]));
|
||||
Assert.Null(NativeRemotePlayerRegistry.ResolvePlatform(["wii", "gamecube"]));
|
||||
Assert.Null(NativeRemotePlayerRegistry.ResolvePlatform(["windows", "ps2"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProxyRequiresHttpsPinnedCertificateServerSideCredentialsAndBoundedPort()
|
||||
{
|
||||
Assert.False(Options(Switch, "http://eden:3001/").Configured);
|
||||
Assert.False(Options(Switch, "https://eden:3001/", username: null).Configured);
|
||||
Assert.False(Options(Switch, "https://eden:3001/", certificate: null).Configured);
|
||||
Assert.False(Options(Switch, "https://eden:3001/path").Configured);
|
||||
Assert.True(Options(Switch, "https://eden:3001/").Configured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProxyOnlyAcceptsItsOwnInternalSidecarHost()
|
||||
{
|
||||
Assert.False(Options(Dolphin, "https://other:3001/").Configured);
|
||||
Assert.False(Options(Dolphin, "https://eden:3001/").Configured);
|
||||
Assert.True(Options(Dolphin, "https://dolphin:3001/").Configured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaunchOriginUsesTheRequestHostAndDedicatedPort()
|
||||
{
|
||||
var options = Options(Switch, "https://eden:3001/", port: 4321);
|
||||
var request = new DefaultHttpContext().Request;
|
||||
request.Scheme = "https";
|
||||
request.Host = new HostString("games.example.test", 1230);
|
||||
|
||||
Assert.Equal("https://games.example.test:4321/switch-player/", options.BuildLaunchUrl(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPlayerOriginMustBeHttpsAndAChildOfTheRequestHost()
|
||||
{
|
||||
var request = new DefaultHttpContext().Request;
|
||||
request.Scheme = "http";
|
||||
request.Host = new HostString("ludarium.example");
|
||||
|
||||
var options = Options(Switch, "https://eden:3001/", port: 4321,
|
||||
origin: "https://player.ludarium.example/");
|
||||
|
||||
Assert.Equal("https://player.ludarium.example/switch-player/", options.BuildLaunchUrl(request));
|
||||
Assert.Equal("https://player.ludarium.example/switch-player/", options.BuildExternalUrl(request));
|
||||
Assert.Equal("ludarium.example", options.CookieDomain(request));
|
||||
Assert.True(options.AcceptsProxyRequest(RequestFor("player.ludarium.example")));
|
||||
Assert.False(options.AcceptsProxyRequest(RequestFor("ludarium.example")));
|
||||
|
||||
Assert.Null(Options(Switch, "https://eden:3001/", origin: "http://player.ludarium.example/").PublicOrigin);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
Options(Switch, "https://eden:3001/", origin: "https://player.example.net/").BuildLaunchUrl(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DolphinPublicOriginMustBeAChildOfTheMainHost()
|
||||
{
|
||||
var options = Options(Dolphin, "https://dolphin:3001/", origin: "https://dolphin.ludarium.test/");
|
||||
var request = new DefaultHttpContext().Request;
|
||||
request.Scheme = "https";
|
||||
request.Host = new HostString("ludarium.test");
|
||||
|
||||
Assert.Equal("https://dolphin.ludarium.test/dolphin-player/", options.BuildLaunchUrl(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsAreOpaqueBoundedExpiringAndExplicitlyEnded()
|
||||
{
|
||||
var time = new MutableTimeProvider(new DateTimeOffset(2026, 8, 11, 20, 0, 0, TimeSpan.Zero));
|
||||
var sessions = new NativeRemotePlayerSessionStore(Switch, time);
|
||||
var first = sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null);
|
||||
var second = sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null);
|
||||
|
||||
Assert.True(sessions.Authorizes(first.Token));
|
||||
Assert.False(sessions.Authorizes(first.Token + "changed"));
|
||||
Assert.Throws<NativeRemotePlayerCapacityException>(() =>
|
||||
sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null));
|
||||
Assert.False(sessions.End(Guid.NewGuid()));
|
||||
Assert.True(sessions.End(first.Session.Id));
|
||||
Assert.False(sessions.Authorizes(first.Token));
|
||||
|
||||
time.Advance(NativeRemotePlayerSessionStore.SessionLifetime.Add(TimeSpan.FromSeconds(1)));
|
||||
Assert.False(sessions.Authorizes(second.Token));
|
||||
Assert.False(sessions.HasActiveSession);
|
||||
Assert.NotNull(sessions.Create(Guid.NewGuid(), "http://host:1231/switch-player/", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapacityFollowsThePlayerDescription()
|
||||
{
|
||||
var sessions = new NativeRemotePlayerSessionStore(Dolphin, TimeProvider.System);
|
||||
var ticket = sessions.Create(Guid.NewGuid(), "https://ludarium.test:1232/dolphin-player/", null, "wii");
|
||||
|
||||
Assert.Equal("Dolphin", ticket.Session.Emulator);
|
||||
Assert.Equal("wii", ticket.Session.Platform);
|
||||
Assert.True(ticket.Session.ExactGameLaunch);
|
||||
Assert.Equal("native-and-savestate-persistent", ticket.Session.SaveMode);
|
||||
Assert.Throws<NativeRemotePlayerCapacityException>(() =>
|
||||
sessions.Create(Guid.NewGuid(), "https://ludarium.test:1232/dolphin-player/", null, "wii"));
|
||||
Assert.True(sessions.End(ticket.Session.Id));
|
||||
Assert.False(sessions.Authorizes(ticket.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionCookieIsHttpOnlyProxyScopedAndStrict()
|
||||
{
|
||||
var sessions = new NativeRemotePlayerSessionStore(Switch, TimeProvider.System);
|
||||
var cookie = sessions.Cookie(DateTimeOffset.UtcNow.AddHours(1), secure: true);
|
||||
|
||||
Assert.True(cookie.HttpOnly);
|
||||
Assert.True(cookie.Secure);
|
||||
Assert.Equal(SameSiteMode.Strict, cookie.SameSite);
|
||||
Assert.Equal("/switch-player", cookie.Path);
|
||||
Assert.Null(cookie.Domain);
|
||||
Assert.Equal(DateTimeOffset.UnixEpoch, sessions.ExpiredCookie(true).Expires);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("not-a-hash", false)]
|
||||
[InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", true)]
|
||||
public void RuntimeQualificationRequiresAnExactFixtureDigest(string? digest, bool expected) =>
|
||||
Assert.Equal(expected, NativeRemotePlayerControlClient.IsValidSha256(digest));
|
||||
|
||||
[Fact]
|
||||
public void ControlConfigurationOnlyAcceptsTheInternalSidecarOrigin()
|
||||
{
|
||||
Assert.True(Control(Switch, "http://eden:8765/", "secret").Configured);
|
||||
Assert.False(Control(Switch, "https://eden:8765/", "secret").Configured);
|
||||
Assert.False(Control(Switch, "http://other:8765/", "secret").Configured);
|
||||
Assert.False(Control(Switch, "http://eden:8765/control/", "secret").Configured);
|
||||
Assert.False(Control(Switch, "http://eden:8765/", null).Configured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APlayerThatRequiresAFixtureStaysUnconfiguredWithoutOne()
|
||||
{
|
||||
Assert.False(Control(Dolphin, "http://dolphin:8765/", "secret").Configured);
|
||||
Assert.True(Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash).Configured);
|
||||
// Switch has no fixture gate, so a digest is neither required nor consulted.
|
||||
Assert.True(Control(Switch, "http://eden:8765/", "secret").Configured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchUsesTheAuthenticatedFixedControllerEndpoint()
|
||||
{
|
||||
Uri? observedUri = null;
|
||||
string? observedToken = null;
|
||||
string? observedBody = null;
|
||||
var control = Control(Switch, "http://eden:8765/", "separate-secret", handler: request =>
|
||||
{
|
||||
observedUri = request.RequestUri;
|
||||
observedToken = request.Headers.GetValues("X-Ludarium-Control-Token").Single();
|
||||
observedBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
return new HttpResponseMessage(HttpStatusCode.Accepted);
|
||||
});
|
||||
|
||||
await control.LaunchAsync("Animal Crossing/base.xci", CancellationToken.None);
|
||||
|
||||
Assert.Equal(new Uri("http://eden:8765/v1/launch"), observedUri);
|
||||
Assert.Equal("separate-secret", observedToken);
|
||||
Assert.Contains("Animal Crossing/base.xci", observedBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogPrefixVariablesMatchTheDeployedConfiguration()
|
||||
{
|
||||
Assert.Equal("CATALOG_PREFIX", NativeRemotePlayerControlClient.CatalogPrefixVariable(Switch, "switch"));
|
||||
Assert.Equal("GAMECUBE_CATALOG_PREFIX",
|
||||
NativeRemotePlayerControlClient.CatalogPrefixVariable(Dolphin, "gamecube"));
|
||||
Assert.Equal("WII_CATALOG_PREFIX", NativeRemotePlayerControlClient.CatalogPrefixVariable(Dolphin, "wii"));
|
||||
Assert.Equal("LUDARIUM_SWITCH", Switch.EnvironmentPrefix);
|
||||
Assert.Equal("LUDARIUM_DOLPHIN", Dolphin.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AConfiguredButUnreachableRuntimeIsNotReported()
|
||||
{
|
||||
var control = Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash,
|
||||
_ => throw new HttpRequestException("connection refused"));
|
||||
|
||||
var status = await control.GetStatusAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(status.Reachable);
|
||||
Assert.False(status.Running);
|
||||
Assert.False(status.SaveDataSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALiveRuntimeReportsItsRunningTitleAndSaveDataSupport()
|
||||
{
|
||||
var control = Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash,
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""{"running":true,"pid":42,"saveMode":"native-and-savestate-persistent","saveData":true}""")
|
||||
});
|
||||
|
||||
var status = await control.GetStatusAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(status.Reachable);
|
||||
Assert.True(status.Running);
|
||||
Assert.True(status.SaveDataSupported);
|
||||
Assert.Equal("native-and-savestate-persistent", status.SaveMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHangingRuntimeCannotStallACapabilityRead()
|
||||
{
|
||||
var control = Control(Dolphin, "http://dolphin:8765/", "secret", CertificateHash, _ =>
|
||||
{
|
||||
// A sidecar that accepts the connection and never answers must not hold up a catalog page.
|
||||
Thread.Sleep(NativeRemotePlayerControlClient.HealthProbeTimeout + TimeSpan.FromSeconds(3));
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
var started = System.Diagnostics.Stopwatch.StartNew();
|
||||
var status = await control.GetStatusAsync(CancellationToken.None);
|
||||
started.Stop();
|
||||
|
||||
Assert.False(status.Reachable);
|
||||
Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"probe took {started.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnconfiguredRuntimeIsNeverProbed()
|
||||
{
|
||||
var probes = 0;
|
||||
var control = Control(Dolphin, "http://dolphin:8765/", "secret", fixture: null, handler: _ =>
|
||||
{
|
||||
probes++;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
Assert.False((await control.GetStatusAsync(CancellationToken.None)).Reachable);
|
||||
Assert.Equal(0, probes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndingASessionStopsTheTitleBestEffort()
|
||||
{
|
||||
var actions = new List<string>();
|
||||
var control = Control(Switch, "http://eden:8765/", "secret", handler: request =>
|
||||
{
|
||||
actions.Add(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
return new HttpResponseMessage(HttpStatusCode.Accepted);
|
||||
});
|
||||
|
||||
Assert.True(await control.TryStopAsync(CancellationToken.None));
|
||||
Assert.Contains("stop", Assert.Single(actions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFailingStopNeverBreaksSessionRevocation()
|
||||
{
|
||||
var control = Control(Switch, "http://eden:8765/", "secret",
|
||||
handler: _ => new HttpResponseMessage(HttpStatusCode.Conflict));
|
||||
|
||||
Assert.False(await control.TryStopAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryPlayerActionIsAllowlisted()
|
||||
{
|
||||
Assert.Contains("stop", Switch.Actions);
|
||||
Assert.DoesNotContain("save-state", Switch.Actions);
|
||||
Assert.False(Switch.SupportsSavestates);
|
||||
Assert.Contains("save-state", Dolphin.Actions);
|
||||
Assert.Contains("load-state", Dolphin.Actions);
|
||||
Assert.True(Dolphin.SupportsSavestates);
|
||||
}
|
||||
|
||||
private static NativeRemotePlayerProxyOptions Options(NativeRemotePlayer player, string destination,
|
||||
string? username = "user", string? certificate = CertificateHash, int? port = null, string? origin = null) =>
|
||||
new(player, new Uri(destination), null, username, "pass", port, certificate,
|
||||
origin is null ? null : new Uri(origin));
|
||||
|
||||
private static NativeRemotePlayerControlClient Control(NativeRemotePlayer player, string address,
|
||||
string? token, string? fixture = null, Func<HttpRequestMessage, HttpResponseMessage>? handler = null) =>
|
||||
new(player, new HttpClient(new StubHandler(handler ?? (_ => new HttpResponseMessage(HttpStatusCode.Accepted))))
|
||||
{ BaseAddress = new Uri(address) }, token,
|
||||
player.Platforms.ToDictionary(platform => platform, platform => $"roms/{platform}"), fixture);
|
||||
|
||||
private static HttpRequest RequestFor(string host)
|
||||
{
|
||||
var request = new DefaultHttpContext().Request;
|
||||
request.Host = new HostString(host);
|
||||
return request;
|
||||
}
|
||||
|
||||
private sealed class MutableTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
public void Advance(TimeSpan amount) => now = now.Add(amount);
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(response(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Ludarium.Api;
|
||||
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
public sealed class RateLimitPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/v1/games", "read", 1200)]
|
||||
[InlineData("POST", "/api/v1/games", "mutation", 120)]
|
||||
[InlineData("POST", "/api/v1/games/artwork/enrich", "expensive", 12)]
|
||||
[InlineData("POST", "/api/v1/games/metadata/enrich", "expensive", 12)]
|
||||
[InlineData("GET", "/api/v1/exports/inventory", "expensive", 12)]
|
||||
public void UsesSeparateStableBudgets(string method, string path, string expectedPartition, int expectedLimit)
|
||||
{
|
||||
var profile = ApiRateLimits.For(method, new(path));
|
||||
Assert.Equal(expectedPartition, profile.Partition);
|
||||
Assert.Equal(expectedLimit, profile.PermitLimit);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), profile.Window);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Ludarium.Api;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.E2E;
|
||||
|
||||
public sealed class ReleaseSurfaceTests
|
||||
{
|
||||
[Fact]
|
||||
public void WebArtifactIsBuiltByFrontendGate() => Assert.True(true, "Browser E2E requires the Docker deployment gate.");
|
||||
|
||||
[Fact]
|
||||
public void BundledEmulatorCorePayloadsHaveAnExplicitContentType()
|
||||
{
|
||||
var provider = StaticAssetContentTypes.Create();
|
||||
|
||||
Assert.True(provider.TryGetContentType("fceumm-wasm.data", out var contentType));
|
||||
Assert.Equal("application/octet-stream", contentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Quick", ScanMode.Quick)]
|
||||
[InlineData("deep", ScanMode.Deep)]
|
||||
[InlineData("INTEGRITY", ScanMode.Integrity)]
|
||||
public void AScanModeIsReadByName(string value, ScanMode expected)
|
||||
{
|
||||
Assert.True(ScanRequests.TryParseMode(value, out var mode));
|
||||
Assert.Equal(expected, mode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Full")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("3")]
|
||||
public void AnUnrecognisedScanModeIsRefusedWithTheModesThatExist(string? value)
|
||||
{
|
||||
// An unrecognised mode used to be an empty 400: no message, no hint at what was expected.
|
||||
Assert.False(ScanRequests.TryParseMode(value, out _));
|
||||
Assert.Contains("Quick", ScanRequests.UnknownModeMessage, StringComparison.Ordinal);
|
||||
Assert.Contains("Deep", ScanRequests.UnknownModeMessage, StringComparison.Ordinal);
|
||||
Assert.Contains("Integrity", ScanRequests.UnknownModeMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[18.8.1, )",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "18.8.1",
|
||||
"Microsoft.TestPlatform.TestHost": "18.8.1"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.18.0",
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.5, )",
|
||||
"resolved": "3.1.5",
|
||||
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "R/1EATnPLU+gRfB6lwVkMcymmyAY5ppBBdRN/5lhNEiT3xP1sWccuSFkU/f1lQvN/WgRq5Vn8AhCdE3fqgsL/w==",
|
||||
"dependencies": {
|
||||
"Microsoft.OpenApi": "[2.7.5, 3.0.0)"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.OpenApi": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.11.0",
|
||||
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.8.1",
|
||||
"contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "18.8.1"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"OpenMcdf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.0",
|
||||
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
|
||||
},
|
||||
"SharpCompress": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.50.4",
|
||||
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
|
||||
},
|
||||
"System.IO.Hashing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.18.0",
|
||||
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]",
|
||||
"xunit.extensibility.execution": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
|
||||
"dependencies": {
|
||||
"xunit.abstractions": "2.0.3"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.execution": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"Yarp.ReverseProxy": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.0",
|
||||
"contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
|
||||
"dependencies": {
|
||||
"System.IO.Hashing": "8.0.0"
|
||||
}
|
||||
},
|
||||
"ludarium.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Application": "[1.0.0, )",
|
||||
"Ludarium.Infrastructure": "[1.0.0, )",
|
||||
"Microsoft.AspNetCore.OpenApi": "[10.0.11, )",
|
||||
"Microsoft.OpenApi": "[2.11.0, )",
|
||||
"Yarp.ReverseProxy": "[2.3.0, )"
|
||||
}
|
||||
},
|
||||
"ludarium.application": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"OpenMcdf": "[3.2.0, )",
|
||||
"SharpCompress": "[0.50.4, )",
|
||||
"System.IO.Hashing": "[10.0.11, )"
|
||||
}
|
||||
},
|
||||
"ludarium.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"ludarium.infrastructure": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Application": "[1.0.0, )",
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"Npgsql": "[10.0.3, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user