Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)
Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.
- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
Documentation-Ereignisses nach - ueber die rohe Collection statt
IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
hier die Original-StorageId erhalten bleiben muss
Round-Trip-Tests belegen byteidentische Uebertragung.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class AttachmentStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
byte[] original = [1, 2, 3, 4, 5, 255, 0, 42];
|
||||
|
||||
await store.StoreAsync("user-1", "abc123", new MemoryStream(original));
|
||||
|
||||
using var read = store.OpenRead("user-1", "abc123");
|
||||
Assert.NotNull(read);
|
||||
using var ms = new MemoryStream();
|
||||
await read!.CopyToAsync(ms);
|
||||
Assert.Equal(original, ms.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenRead_UnbekannteStorageId_GibtNullZurueck()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
Assert.Null(store.OpenRead("user-1", "unbekannt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
||||
|
||||
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
||||
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
// Darf keinesfalls außerhalb von <root>/attachments/<user> landen.
|
||||
await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3]));
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(temp.Path, "evil")));
|
||||
var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories);
|
||||
Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil");
|
||||
}
|
||||
|
||||
private sealed class TempDataPath : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}");
|
||||
|
||||
public TempDataPath() => Directory.CreateDirectory(Path);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user