This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user