Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs
T
RichardandClaude Opus 5.5 837a634a22 Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig
erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner
Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft.
Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch.

WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b
umbenannten Parameter angeglichen.

Dabei behoben:
- 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue"
  gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day.
- Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine
  CSS-Klasse gab - jetzt "success" wie laufende Instanzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:45:03 +02:00

526 lines
22 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>
/// An unchecked restore is not a backup, but a guess.
/// That's why the focus here is on the complete round trip.
///
/// Runs in <see cref="SecretKeyCollection"/>, because rewriting the secrets
/// when backing up uses the same static key as the secret tests.
/// </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()
{
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Cleaning up is secondary */ }
}
// ─── Building a realistic instance ───
private async Task BuildInstanceAsync()
{
// Instance configuration — credentials are DPAPI-protected as in operation.
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");
// Database with a memory — the most valuable part.
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);
// Close connections so VACUUM INTO meets a quiet file.
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
}
private string ZipPath => Path.Combine(_root, "sicherung.zip");
private string RestoreDir => Path.Combine(_root, "wiederhergestellt");
// ═══════════════════════════════════════════════════════════
// The round trip
// ═══════════════════════════════════════════════════════════
[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();
// Personality
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");
// Work state
File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Workspace", "bericht.md"))
.ShouldContain("Inhalt");
// Database including memory
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()
{
// The actual purpose of the passphrase: a backup is needed when the
// machine is broken — DPAPI-protected values would then be unreadable.
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);
}
/// <summary>
/// The move to another machine — the migration path from #190.
///
/// <para>The tests above back up and restore in the same run and
/// use the same key in doing so. They therefore cannot show what matters
/// when moving: that the values are readable even if the key of the
/// source machine does <b>not</b> come along. Exactly that is the case Windows → Linux —
/// the existing values are DPAPI-encrypted, and
/// <c>SecretProtector.UnprotectV1</c> deliberately throws for them on Linux.</para>
///
/// <para>Here the key therefore changes in the middle of the test. Without the change
/// the test would be green without proving anything.</para>
/// </summary>
[Fact]
public async Task Der_Umzug_auf_einen_anderen_Rechner_traegt()
{
var zielMaschine = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
try
{
// Machine 1: values lie under the local key.
await BuildInstanceAsync();
var schluesselDerQuelle = File.ReadAllBytes(SecretKeyStore.KeyFilePath);
await _service.CreateAsync(_instanceDir, ZipPath,
new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "umzug" });
// Machine 2: different machine, different key. The key does not travel
// along — it deliberately lies outside the instance directory.
SecretKeyStore.UseDirectory(zielMaschine);
await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "umzug" });
File.ReadAllBytes(SecretKeyStore.KeyFilePath)
.ShouldNotBe(schluesselDerQuelle, "sonst wäre es derselbe Rechner");
var settings = JsonDocument.Parse(
File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json")));
var stored = settings.RootElement.GetProperty("openRouterApiKey").GetString();
SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain);
// The nested values too, not only the obvious one at the top.
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);
}
finally
{
// A directory of its own for the next test of the collection: that of the
// source machine is no longer available here, and the real
// user key must certainly not be it.
SecretKeyStore.UseDirectory(
Path.Combine(Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
try { Directory.Delete(zielMaschine, recursive: true); } catch { /* secondary */ }
}
}
[Fact]
public async Task Ohne_die_Passphrase_ist_der_Umzug_verloren()
{
// The flip side, and the reason why the passphrase has to be offered when backing
// up: without it a backup on the target machine is worthless as far as the
// credentials are concerned. That is not an error, but the price for the
// key not travelling along — but it has to be noticed instead of happening silently.
var zielMaschine = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
try
{
await BuildInstanceAsync();
await _service.CreateAsync(_instanceDir, ZipPath,
new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "umzug" });
SecretKeyStore.UseDirectory(zielMaschine);
await Should.ThrowAsync<Exception>(
() => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "falsch" }));
}
finally
{
SecretKeyStore.UseDirectory(
Path.Combine(Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
try { Directory.Delete(zielMaschine, recursive: true); } catch { /* secondary */ }
}
}
[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()
{
// A backup must not throw anything away just because a program version does not know
// a field.
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");
}
// ═══════════════════════════════════════════════════════════
// Leaving out credentials
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ohne_Zugangsdaten_enthaelt_das_Archiv_keine_Geheimnisse()
{
await BuildInstanceAsync();
await _service.CreateAsync(_instanceDir, ZipPath,
new BackupOptions { Secrets = SecretMode.Exclude });
// Search the entire archive in plain text.
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 and error cases
// ═══════════════════════════════════════════════════════════
[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());
// Change a file in the archive afterwards.
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");
}
// ═══════════════════════════════════════════════════════════
// Preview and overwriting
// ═══════════════════════════════════════════════════════════
[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");
}
// ═══════════════════════════════════════════════════════════
// Scope
// ═══════════════════════════════════════════════════════════
[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 already incorporates them — backing them up too would be misleading.
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 }));
}
// ─── Helpers ───
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();
}
}