435 lines
17 KiB
C#
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();
|
|
}
|
|
}
|