Files

435 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 ClawdDotNet.Core.Tests.Infrastructure;
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.
///
/// Läuft in <see cref="SecretKeyCollection"/>, weil das Umschreiben der Geheimnisse
/// beim Sichern denselben statischen Schlüssel benutzt wie die Geheimnistests.
/// </summary>
[Collection(SecretKeyCollection.Name)]
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();
}
}