2026-09-11 22:02:46 +00:00
|
|
|
using System.Text;
|
|
|
|
|
using w4c_workflows.Services.Nodes.Binary;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Unit tests for the content-addressed filesystem binary store.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class FileSystemBinaryStoreTests
|
|
|
|
|
{
|
|
|
|
|
private static async Task<byte[]> ReadAllAsync(Stream stream)
|
|
|
|
|
{
|
|
|
|
|
using var buffer = new MemoryStream();
|
|
|
|
|
await stream.CopyToAsync(buffer);
|
|
|
|
|
return buffer.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Save_then_open_round_trips_bytes_and_metadata()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
var bytes = Encoding.UTF8.GetBytes("hello binary");
|
|
|
|
|
|
|
|
|
|
var attachment = await store.SaveAsync(
|
|
|
|
|
new MemoryStream(bytes), "greeting.txt", "text/plain");
|
|
|
|
|
|
|
|
|
|
Assert.StartsWith("sha256:", attachment.AssetId);
|
|
|
|
|
Assert.Equal("greeting.txt", attachment.FileName);
|
|
|
|
|
Assert.Equal("text/plain", attachment.MimeType);
|
|
|
|
|
Assert.Equal(bytes.Length, attachment.SizeBytes);
|
|
|
|
|
|
|
|
|
|
await using var opened = await store.OpenAsync(attachment.AssetId);
|
|
|
|
|
Assert.NotNull(opened);
|
|
|
|
|
Assert.Equal(bytes, await ReadAllAsync(opened!));
|
|
|
|
|
|
|
|
|
|
var described = await store.DescribeAsync(attachment.AssetId);
|
|
|
|
|
Assert.Equal(attachment, described);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Identical_content_is_content_addressed_to_one_asset()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
var bytes = Encoding.UTF8.GetBytes("same bytes");
|
|
|
|
|
|
|
|
|
|
var first = await store.SaveAsync(new MemoryStream(bytes), "a.bin", "application/octet-stream");
|
|
|
|
|
var second = await store.SaveAsync(new MemoryStream(bytes), "b.bin", "application/octet-stream");
|
|
|
|
|
|
|
|
|
|
Assert.Equal(first.AssetId, second.AssetId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Different_content_yields_different_assets()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
|
|
|
|
|
var first = await store.SaveAsync(new MemoryStream([1, 2, 3]), "a.bin", null);
|
|
|
|
|
var second = await store.SaveAsync(new MemoryStream([4, 5, 6]), "b.bin", null);
|
|
|
|
|
|
|
|
|
|
Assert.NotEqual(first.AssetId, second.AssetId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Open_can_be_called_more_than_once()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
var bytes = Encoding.UTF8.GetBytes("repeatable");
|
|
|
|
|
var attachment = await store.SaveAsync(new MemoryStream(bytes), null, null);
|
|
|
|
|
|
|
|
|
|
await using var first = await store.OpenAsync(attachment.AssetId);
|
|
|
|
|
await using var second = await store.OpenAsync(attachment.AssetId);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(bytes, await ReadAllAsync(first!));
|
|
|
|
|
Assert.Equal(bytes, await ReadAllAsync(second!));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData("")]
|
|
|
|
|
[InlineData("not-a-sha")]
|
|
|
|
|
[InlineData("sha256:../../etc/passwd")]
|
|
|
|
|
[InlineData("sha256:zzzz")]
|
|
|
|
|
public async Task Malformed_ids_are_rejected(string assetId)
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
|
|
|
|
|
Assert.Null(await store.OpenAsync(assetId));
|
|
|
|
|
Assert.Null(await store.DescribeAsync(assetId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Valid_but_unknown_id_returns_null()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
var id = FileSystemBinaryStore.BuildAssetId(new string('a', 64));
|
|
|
|
|
|
|
|
|
|
Assert.Null(await store.OpenAsync(id));
|
|
|
|
|
Assert.Null(await store.DescribeAsync(id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Save_rejects_payload_over_the_cap()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store(o => o.MaxBytes = 3);
|
|
|
|
|
|
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
|
|
|
store.SaveAsync(new MemoryStream(Encoding.UTF8.GetBytes("four")), "x", null));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Blank_name_and_mime_are_normalised_to_null()
|
|
|
|
|
{
|
|
|
|
|
var store = BinaryTestData.Store();
|
|
|
|
|
|
|
|
|
|
var attachment = await store.SaveAsync(new MemoryStream([9]), " ", " ");
|
|
|
|
|
|
|
|
|
|
Assert.Null(attachment.FileName);
|
|
|
|
|
Assert.Null(attachment.MimeType);
|
|
|
|
|
}
|
2026-09-13 16:28:47 +00:00
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Constructor_sweeps_stale_temp_files_and_keeps_fresh_ones()
|
|
|
|
|
{
|
|
|
|
|
var root = Directory.CreateTempSubdirectory("w4c-binary-sweep-").FullName;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var stalePayload = Path.Combine(root, "tmp-stale");
|
|
|
|
|
var staleMeta = Path.Combine(root, "ab", "deadbeef.json.tmp-stale");
|
|
|
|
|
var freshPayload = Path.Combine(root, "tmp-fresh");
|
|
|
|
|
Directory.CreateDirectory(Path.Combine(root, "ab"));
|
|
|
|
|
File.WriteAllText(stalePayload, "x");
|
|
|
|
|
File.WriteAllText(staleMeta, "x");
|
|
|
|
|
File.WriteAllText(freshPayload, "x");
|
|
|
|
|
|
|
|
|
|
var old = DateTime.UtcNow.AddHours(-12);
|
|
|
|
|
File.SetLastWriteTimeUtc(stalePayload, old);
|
|
|
|
|
File.SetLastWriteTimeUtc(staleMeta, old);
|
|
|
|
|
|
|
|
|
|
// Constructing the store runs the startup sweep.
|
|
|
|
|
_ = new FileSystemBinaryStore(new BinaryStoreOptions { RootPath = root });
|
|
|
|
|
|
|
|
|
|
Assert.False(File.Exists(stalePayload));
|
|
|
|
|
Assert.False(File.Exists(staleMeta));
|
|
|
|
|
Assert.True(File.Exists(freshPayload));
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
try { Directory.Delete(root, recursive: true); } catch (IOException) { }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 22:02:46 +00:00
|
|
|
}
|