Gesichert wird alles, was sich nicht wiederherstellen laesst: Identity und Soul, Konfigurationen, Arbeitsverzeichnisse, Chatverlaeufe und vor allem die Datenbank mit dem Langzeitgedaechtnis. Protokolle bleiben standardmaessig aussen vor. Zwei Punkte waren dabei nicht offensichtlich. Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die juengsten Aenderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie waere veraltet oder in sich widersprueckhlich. VACUUM INTO erzeugt dagegen im laufenden Betrieb eine geschlossene, konsistente Kopie; die WAL-Begleitdateien gehoeren dann nicht mehr ins Archiv. Zugangsdaten sind seit S7 mit DPAPI geschuetzt und damit an Benutzer und Rechner gebunden. In einer Sicherung waeren sie genau dann unbrauchbar, wenn man sie braucht — bei einem defekten Rechner. PassphraseProtector schluesselt sie deshalb beim Sichern auf eine Passphrase um (PBKDF2 mit 210.000 Runden, AES-GCM) und beim Wiederherstellen zurueck auf DPAPI des Zielrechners. Alternativ laesst sich eine Sicherung ganz ohne Zugangsdaten erstellen; sie ist dann gefahrlos ablegbar, die Wiederherstellung aber unvollstaendig. Das Umschluesseln arbeitet auf dem JSON-Baum statt ueber die typisierten Konfigurationsklassen. Beim Deserialisieren und erneuten Serialisieren gingen unbekannte Felder verloren — eine Sicherung darf aber nichts wegwerfen, nur weil eine Programmfassung ein Feld nicht kennt. Ein Test haelt das fest. Weitere Eigenschaften: Manifest mit Pruefsummen je Datei, sodass ein veraendertes Archiv auffaellt, bevor etwas ueberschrieben wird. Vorschau-Modus. Vorhandene Dateien werden ohne ausdrueckliche Zustimmung nicht ueberschrieben. Eintraege, die aus dem Zielverzeichnis herauszeigen, werden abgelehnt. Der wichtigste Test ist der vollstaendige Rundlauf: Instanz aufbauen, sichern, in ein leeres Verzeichnis wiederherstellen und pruefen, dass Persoenlichkeit, Arbeitsstand, Gedaechtnis und nutzbare Zugangsdaten zurueck sind. Ein ungepruefte Wiederherstellung ist kein Backup, sondern eine Vermutung. 385 Tests gruen (237 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
430 lines
17 KiB
C#
430 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung.
|
|
/// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf.
|
|
/// </summary>
|
|
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<BackupException>(
|
|
() => _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<SecretProtectionException>(
|
|
() => _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<BackupException>(
|
|
() => _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<BackupException>(() => _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<BackupException>(
|
|
() => _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<BackupException>(() => _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();
|
|
}
|
|
}
|