using System.IO.Compression; using System.Text.Json; using ClawdDotNet.Core.Backup; using ClawdDotNet.Core.Memory; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; using Shouldly; namespace ClawdDotNet.Core.Tests.Backup; /// /// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung. /// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf. /// public sealed class BackupServiceTests : IDisposable { private readonly string _root; private readonly string _instanceDir; private readonly BackupService _service = new(); private const string ApiKeyPlain = "sk-or-v1-streng-geheim-12345"; private const string MailPasswordPlain = "mail-passwort-geheim"; public BackupServiceTests() { _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _instanceDir = Path.Combine(_root, "Instance-Test"); Directory.CreateDirectory(_instanceDir); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_root, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } // ─── Aufbau einer realistischen Instanz ─── private async Task BuildInstanceAsync() { // Instanzkonfiguration — Zugangsdaten liegen DPAPI-geschützt wie im Betrieb. var instanceSettings = $$""" { "instanceId": "test-01", "instanceName": "Testinstanz", "openRouterApiKey": "{{SecretProtector.Protect(ApiKeyPlain)}}", "webServerPort": 8080, "einUnbekanntesFeld": "muss erhalten bleiben" } """; AtomicFile.WriteAllText(Path.Combine(_instanceDir, "InstanceSettings.json"), instanceSettings); // Agent var agentDir = Path.Combine(_instanceDir, "Agents", "Agent-Analyst"); Directory.CreateDirectory(Path.Combine(agentDir, "Workspace")); Directory.CreateDirectory(Path.Combine(agentDir, "Logs")); var agentSettings = $$""" { "agentId": "analyst", "displayName": "Analyst", "model": "anthropic/claude-sonnet-4-5", "tools": { "Mail": { "smtpHost": "smtp.example.com", "password": "{{SecretProtector.Protect(MailPasswordPlain)}}" } } } """; AtomicFile.WriteAllText(Path.Combine(agentDir, "AgentSettings.json"), agentSettings); AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), "# Identity\nDer Analyst."); AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), "# Soul\nGründlich und knapp."); AtomicFile.WriteAllText(Path.Combine(agentDir, "ChatHistory.json"), """[{"role":"user"}]"""); AtomicFile.WriteAllText(Path.Combine(agentDir, "Workspace", "bericht.md"), "# Bericht\nInhalt."); AtomicFile.WriteAllText(Path.Combine(agentDir, "Logs", "lauf.log"), "viele Zeilen Protokoll"); // Datenbank mit einer Erinnerung — der wertvollste Teil. var storage = new SqliteStorage(Path.Combine(_instanceDir, "state.db")); var memory = new SqliteMemoryRepository(storage); await memory.RememberAsync(new MemoryEntry { Scope = MemoryScope.Agent, OwnerId = "analyst", Subject = "NVDA", Content = "Muss die Sicherung überstehen", Key = "kernaussage", CreatedBy = "analyst" }, default); // Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft. Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); } private string ZipPath => Path.Combine(_root, "sicherung.zip"); private string RestoreDir => Path.Combine(_root, "wiederhergestellt"); // ═══════════════════════════════════════════════════════════ // Der Rundlauf // ═══════════════════════════════════════════════════════════ [Fact] public async Task Der_vollstaendige_Rundlauf_stellt_alles_wieder_her() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); var result = await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); result.Written.ShouldNotBeEmpty(); // Persönlichkeit File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Identity.md")) .ShouldContain("Der Analyst"); File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Soul.md")) .ShouldContain("Gründlich"); // Arbeitsstand File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Workspace", "bericht.md")) .ShouldContain("Inhalt"); // Datenbank samt Gedächtnis File.Exists(Path.Combine(RestoreDir, "state.db")).ShouldBeTrue(); var memory = new SqliteMemoryRepository(new SqliteStorage(Path.Combine(RestoreDir, "state.db"))); var recalled = await memory.RecallAsync( new MemoryQuery { Scope = MemoryScope.Agent, OwnerId = "analyst" }, default); recalled.ShouldHaveSingleItem(); recalled[0].Content.ShouldBe("Muss die Sicherung überstehen"); } [Fact] public async Task Zugangsdaten_sind_nach_dem_Wiederherstellen_wieder_nutzbar() { // Der eigentliche Zweck der Passphrase: Ein Backup wird gebraucht, wenn der // Rechner defekt ist — DPAPI-geschützte Werte wären dann unlesbar. await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); var settings = JsonDocument.Parse( File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json"))); var stored = settings.RootElement.GetProperty("openRouterApiKey").GetString(); SecretProtector.IsProtected(stored).ShouldBeTrue("wieder mit DPAPI geschützt"); SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain); } [Fact] public async Task Auch_verschachtelte_Zugangsdaten_in_Tool_Konfigurationen_kommen_zurueck() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); var agent = JsonDocument.Parse(File.ReadAllText( Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "AgentSettings.json"))); var password = agent.RootElement .GetProperty("tools").GetProperty("Mail").GetProperty("password").GetString(); SecretProtector.Unprotect(password).ShouldBe(MailPasswordPlain); } [Fact] public async Task Unbekannte_Felder_ueberstehen_den_Rundlauf() { // Eine Sicherung darf nichts wegwerfen, nur weil eine Programmfassung ein // Feld nicht kennt. await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json")) .ShouldContain("einUnbekanntesFeld"); } // ═══════════════════════════════════════════════════════════ // Zugangsdaten weglassen // ═══════════════════════════════════════════════════════════ [Fact] public async Task Ohne_Zugangsdaten_enthaelt_das_Archiv_keine_Geheimnisse() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Exclude }); // Das gesamte Archiv im Klartext durchsuchen. var inhalt = ReadWholeArchive(ZipPath); inhalt.ShouldNotContain(ApiKeyPlain); inhalt.ShouldNotContain(MailPasswordPlain); } [Fact] public async Task Eine_Sicherung_mit_Passphrase_zeigt_die_Geheimnisse_nicht_im_Klartext() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); var inhalt = ReadWholeArchive(ZipPath); inhalt.ShouldNotContain(ApiKeyPlain); inhalt.ShouldNotContain(MailPasswordPlain); inhalt.ShouldContain("pbe:v1:"); } // ═══════════════════════════════════════════════════════════ // Manifest und Fehlerfälle // ═══════════════════════════════════════════════════════════ [Fact] public async Task Das_Manifest_beschreibt_die_Sicherung() { await BuildInstanceAsync(); var result = await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); var manifest = await _service.InspectAsync(ZipPath); manifest.InstanceId.ShouldBe("test-01"); manifest.InstanceName.ShouldBe("Testinstanz"); manifest.HasSecrets.ShouldBeTrue(); manifest.SecretCount.ShouldBe(2, "API-Schlüssel und Mail-Passwort"); manifest.Files.ShouldNotBeEmpty(); result.SizeBytes.ShouldBeGreaterThan(0); } [Fact] public async Task Ohne_Passphrase_wird_nicht_wiederhergestellt() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); var ex = await Should.ThrowAsync( () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions())); ex.Message.ShouldContain("Passphrase"); } [Fact] public async Task Eine_falsche_Passphrase_wird_erkannt() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "richtig" }); await Should.ThrowAsync( () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "falsch" })); } [Fact] public async Task Ein_veraendertes_Archiv_faellt_auf() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); // Eine Datei im Archiv nachträglich verändern. using (var archive = ZipFile.Open(ZipPath, ZipArchiveMode.Update)) { var entry = archive.Entries.First(e => e.FullName.EndsWith("Identity.md")); using var stream = entry.Open(); stream.SetLength(0); using var writer = new StreamWriter(stream); writer.Write("manipuliert"); } var ex = await Should.ThrowAsync( () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions())); ex.Message.ShouldContain("Prüfsumme"); } [Fact] public async Task Ein_Archiv_ohne_Manifest_wird_abgelehnt() { var fremd = Path.Combine(_root, "fremd.zip"); var quelle = Path.Combine(_root, "quelle"); Directory.CreateDirectory(quelle); File.WriteAllText(Path.Combine(quelle, "irgendwas.txt"), "Inhalt"); ZipFile.CreateFromDirectory(quelle, fremd); var ex = await Should.ThrowAsync(() => _service.InspectAsync(fremd)); ex.Message.ShouldContain("Manifest"); } // ═══════════════════════════════════════════════════════════ // Vorschau und Überschreiben // ═══════════════════════════════════════════════════════════ [Fact] public async Task Die_Vorschau_schreibt_nichts() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); var result = await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { DryRun = true }); result.Written.ShouldBeEmpty(); Directory.Exists(RestoreDir).ShouldBeFalse(); } [Fact] public async Task Vorhandene_Dateien_werden_ohne_Zustimmung_nicht_ueberschrieben() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); Directory.CreateDirectory(RestoreDir); var vorhanden = Path.Combine(RestoreDir, "InstanceSettings.json"); AtomicFile.WriteAllText(vorhanden, """{"wichtig":"nicht verlieren"}"""); var result = await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions()); result.HasConflicts.ShouldBeTrue(); result.Skipped.ShouldContain("InstanceSettings.json"); File.ReadAllText(vorhanden).ShouldContain("nicht verlieren"); } [Fact] public async Task Mit_Zustimmung_wird_ueberschrieben() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); Directory.CreateDirectory(RestoreDir); var vorhanden = Path.Combine(RestoreDir, "InstanceSettings.json"); AtomicFile.WriteAllText(vorhanden, """{"alt":true}"""); await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Overwrite = true }); File.ReadAllText(vorhanden).ShouldContain("Testinstanz"); } // ═══════════════════════════════════════════════════════════ // Umfang // ═══════════════════════════════════════════════════════════ [Fact] public async Task Protokolle_bleiben_standardmaessig_aussen_vor() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); var manifest = await _service.InspectAsync(ZipPath); manifest.Files.ShouldNotContain(f => f.Path.Contains("Logs/")); } [Fact] public async Task Protokolle_lassen_sich_einschliessen() { await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { IncludeLogs = true }); var manifest = await _service.InspectAsync(ZipPath); manifest.Files.ShouldContain(f => f.Path.Contains("Logs/")); } [Fact] public async Task Die_WAL_Begleitdateien_landen_nicht_im_Archiv() { // VACUUM INTO arbeitet sie bereits ein — mitzusichern wäre irreführend. await BuildInstanceAsync(); await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); var manifest = await _service.InspectAsync(ZipPath); manifest.Files.ShouldNotContain(f => f.Path.EndsWith("-wal") || f.Path.EndsWith("-shm")); manifest.Files.ShouldContain(f => f.Path == "state.db"); } [Fact] public async Task Ein_fehlendes_Instanzverzeichnis_wird_gemeldet() { var ex = await Should.ThrowAsync( () => _service.CreateAsync(Path.Combine(_root, "gibtsnicht"), ZipPath, new BackupOptions())); ex.Message.ShouldContain("nicht gefunden"); } [Fact] public async Task Passphrase_Modus_ohne_Passphrase_wird_abgelehnt() { await BuildInstanceAsync(); await Should.ThrowAsync(() => _service.CreateAsync( _instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase })); } // ─── Helfer ─── private static string ReadWholeArchive(string zipPath) { using var archive = ZipFile.OpenRead(zipPath); var sb = new System.Text.StringBuilder(); foreach (var entry in archive.Entries) { using var stream = entry.Open(); using var buffer = new MemoryStream(); stream.CopyTo(buffer); sb.Append(System.Text.Encoding.UTF8.GetString(buffer.ToArray())); } return sb.ToString(); } }