Eine Sicherung des Managers ist jetzt als Ganzes mit dem Lizenzschluessel verschluesselt. Beim Zurueckspielen wird der eigene Schluessel probiert; passt er nicht, fragt die Oberflaeche nach dem Lizenzschluessel der Quellinstallation. Der Lizenzschluessel ist der eine Wert, den jede Installation ohnehin hat, und er liegt im Manager, nicht neben den Archiven. Damit faellt der Grund weg, aus dem die taegliche Sicherung bisher keine Zugangsdaten enthielt: Eine Passphrase haette gespeichert werden muessen, und neben den Sicherungen waere sie wirkungslos gewesen. Jetzt reisen die Zugangsdaten mit, und eine Sicherung uebersteht den Maschinenwechsel ohne Passphrase. LicenseBackupCipher: eigener Behaelter .clawdbak. Kopf mit Schluesselprobe, das Manifest als eigener Abschnitt, das ZIP in 1-MiB-Stuecken mit AES-256-GCM und Endmarke. PBKDF2 aus Lizenzschluessel und festem Salt, der Schluessel normiert. Kein RAR (braucht das lizenzpflichtige Herstellerprogramm), kein ZIP mit Kennwort (kann .NET nicht schreiben, ZipCrypto ist gebrochen, und mit abgeleitetem Schluessel braechte 7-Zip-Vertraeglichkeit nichts). Zurueckspielen: Die Schluesselprobe laeuft VOR dem Anhalten der Instanz. Passt der Schluessel nicht, antwortet der Endpunkt mit 422 und dem Code fremde_lizenz, und ein Fenster verlangt den Schluessel der Quelle. ManagerBackups: Gefuehrte Instanzen sichern sich nicht mehr selbst, weil nur der Manager den Schluessel hat; er liest ihre Einstellungen aus InstanceSettings.json. Einmal am Tag auch ueber einen Neustart des Managers hinweg. Ohne Lizenzschluessel wird nichts gesichert, auch nicht ersatzweise unverschluesselt. FTP (FluentFTP, schon im Loesungsbaum): TLS wo angeboten, requireTls erzwingt es; Upload unter .part-Namen und danach umbenannt; Aufraeumen auf dem Server je Instanz; ein gescheiterter Upload laesst die lokale Sicherung stehen. Das Kennwort liegt verschluesselt in manager.json und geht nie an die Oberflaeche. Welche Datei zu welcher Instanz gehoert, entscheidet ein Zeitstempel nach dem Namen und nicht das Praefix - Test und Test_2 werden nicht mehr verwechselt. Grenze, bewusst und dokumentiert (Konzept 9.10): Wer Lizenzschluessel und Archiv hat, hat die Zugangsdaten. Der Schluessel ist dem Hersteller bekannt und gilt fuer alle Instanzen einer Installation. Nicht ueber die Oberflaeche abgenommen: Fenster fuer die fremde Lizenz und FTP gegen einen echten Server stehen als Block H in #537. 860 Tests gruen, 0 Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
9.9 KiB
C#
284 lines
9.9 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Backup;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Instances;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Manager.Tests;
|
|
|
|
/// <summary>
|
|
/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am
|
|
/// Tag, aufgeräumt, auf Wunsch hochgeladen.
|
|
/// </summary>
|
|
public sealed class ManagerBackupsTests : IAsyncDisposable
|
|
{
|
|
private const string Lizenz = "CLAWD-TEST-0000-1111-2222";
|
|
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "mbak-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private readonly InstanceDirectoryManager _verzeichnisse;
|
|
private readonly ManagerSettingsStore _settings;
|
|
private readonly FakeUploader _upload = new();
|
|
private readonly FesteUhr _uhr = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero));
|
|
|
|
public ManagerBackupsTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
|
_settings = new ManagerSettingsStore(_root);
|
|
_settings.Settings.LicenseKey = Lizenz;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Task.CompletedTask;
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14)
|
|
{
|
|
var pfad = _verzeichnisse.CreateInstance(name);
|
|
var datei = Path.Combine(pfad, "InstanceSettings.json");
|
|
|
|
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(datei))!;
|
|
config.AutoBackupEnabled = automatisch;
|
|
config.AutoBackupTime = uhrzeit;
|
|
config.BackupKeepCount = behalten;
|
|
File.WriteAllText(datei, JsonSerializer.Serialize(config));
|
|
|
|
return pfad;
|
|
}
|
|
|
|
private async Task<ManagerBackups> DienstAsync(IBackupUploader? upload = null)
|
|
{
|
|
var register = new InstanceRegistry(
|
|
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
|
|
SupervisionOptions.Default);
|
|
|
|
await register.RefreshAsync();
|
|
|
|
return new ManagerBackups(
|
|
_settings, register, upload ?? _upload, NullLogger.Instance, _uhr);
|
|
}
|
|
|
|
private static string[] Sicherungen(string pfad)
|
|
{
|
|
var ordner = Path.Combine(pfad, "Backups");
|
|
return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : [];
|
|
}
|
|
|
|
// ─── Namen ───
|
|
|
|
/// <summary>
|
|
/// Ein Präfix ist keine Grenze: Die Archive von <c>Test_2</c> gehören nicht zu
|
|
/// <c>Test</c> — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt()
|
|
{
|
|
var eigene = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0));
|
|
var fremde = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0));
|
|
|
|
BackupNaming.BelongsTo(eigene, "Test").ShouldBeTrue();
|
|
BackupNaming.BelongsTo(fremde, "Test").ShouldBeFalse();
|
|
BackupNaming.BelongsTo(fremde, "Test_2").ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // taegliche Sicherung der Instanz
|
|
[InlineData("backup_Alpha_20260911_030000.zip")] // erste Verwaltungsansicht
|
|
[InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager
|
|
public void Aeltere_Schreibweisen_werden_erkannt(string name)
|
|
{
|
|
BackupNaming.TimestampOf(name, "Alpha").ShouldBe(new DateTime(2026, 9, 11, 3, 0, 0));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("notizen.txt")]
|
|
[InlineData("backup_Alpha_kaputt.zip")]
|
|
[InlineData("backup_Alpha_2026-09-11_0300.zip.part")]
|
|
public void Fremde_Dateien_gehoeren_niemandem(string name)
|
|
{
|
|
BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse();
|
|
}
|
|
|
|
// ─── Sichern ───
|
|
|
|
[Fact]
|
|
public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt()
|
|
{
|
|
var pfad = Instanz("Alpha");
|
|
_settings.Settings.LicenseKey = "";
|
|
|
|
await using var dienst = await DienstAsync();
|
|
var instanz = (await RegisterAsync()).Single();
|
|
|
|
await Should.ThrowAsync<BackupException>(() =>
|
|
dienst.CreateAsync(instanz, false, true, CancellationToken.None));
|
|
|
|
Sicherungen(pfad).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Eine_Sicherung_ist_verschluesselt_und_wird_hochgeladen()
|
|
{
|
|
var pfad = Instanz("Alpha");
|
|
|
|
await using var dienst = await DienstAsync();
|
|
var instanz = (await RegisterAsync()).Single();
|
|
|
|
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
|
|
|
|
LicenseBackupCipher.IsEncrypted(ergebnis.Path).ShouldBeTrue();
|
|
LicenseBackupCipher.MatchesKey(ergebnis.Path, Lizenz).ShouldBeTrue();
|
|
|
|
ergebnis.Uploaded.ShouldBeTrue();
|
|
_upload.Hochgeladen.ShouldBe([ergebnis.Path]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für
|
|
/// misslungen zu erklären — sie liegt ja lokal.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen()
|
|
{
|
|
Instanz("Alpha");
|
|
_upload.Fehler = new IOException("Server nicht erreichbar");
|
|
|
|
await using var dienst = await DienstAsync();
|
|
var instanz = (await RegisterAsync()).Single();
|
|
|
|
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
|
|
|
|
File.Exists(ergebnis.Path).ShouldBeTrue();
|
|
ergebnis.Uploaded.ShouldBeFalse();
|
|
ergebnis.UploadProblem.ShouldBe("Server nicht erreichbar");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Aufgeraeumt_wird_nur_die_eigene_Instanz()
|
|
{
|
|
var ordner = Path.Combine(_root, "ablage");
|
|
Directory.CreateDirectory(ordner);
|
|
|
|
for (var tag = 1; tag <= 5; tag++)
|
|
{
|
|
var zeit = new DateTime(2026, 9, tag, 3, 0, 0);
|
|
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test", zeit)), "");
|
|
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test_2", zeit)), "");
|
|
}
|
|
|
|
await using var dienst = await DienstAsync();
|
|
dienst.Rotate(ordner, "Test", keep: 2);
|
|
|
|
var namen = Directory.GetFiles(ordner).Select(Path.GetFileName).ToList();
|
|
|
|
namen.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2);
|
|
namen.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt.");
|
|
namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
|
|
}
|
|
|
|
// ─── Der tägliche Takt ───
|
|
|
|
[Fact]
|
|
public async Task Vor_der_Uhrzeit_passiert_nichts()
|
|
{
|
|
var pfad = Instanz("Alpha", uhrzeit: "03:00");
|
|
|
|
await using var dienst = await DienstAsync();
|
|
await dienst.TickAsync(); // 02:00
|
|
|
|
Sicherungen(pfad).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Einmal_am_Tag_und_nicht_oefter()
|
|
{
|
|
var pfad = Instanz("Alpha", uhrzeit: "03:00");
|
|
|
|
await using var dienst = await DienstAsync();
|
|
|
|
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5); // 03:30
|
|
await dienst.TickAsync();
|
|
|
|
_uhr.Jetzt = _uhr.Jetzt.AddMinutes(30); // 04:00
|
|
await dienst.TickAsync();
|
|
|
|
Sicherungen(pfad).Length.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auch über einen Neustart des Managers hinweg. Nur im Speicher zu zählen hieße: Wer
|
|
/// nach der Sicherungszeit neu startet, bekommt eine zweite Sicherung.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung()
|
|
{
|
|
var pfad = Instanz("Alpha", uhrzeit: "03:00");
|
|
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5);
|
|
|
|
await using (var erster = await DienstAsync())
|
|
await erster.TickAsync();
|
|
|
|
_uhr.Jetzt = _uhr.Jetzt.AddHours(1);
|
|
|
|
await using (var zweiter = await DienstAsync())
|
|
await zweiter.TickAsync();
|
|
|
|
Sicherungen(pfad).Length.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ohne_Schalter_keine_automatische_Sicherung()
|
|
{
|
|
var pfad = Instanz("Alpha", automatisch: false);
|
|
_uhr.Jetzt = _uhr.Jetzt.AddHours(5);
|
|
|
|
await using var dienst = await DienstAsync();
|
|
await dienst.TickAsync();
|
|
|
|
Sicherungen(pfad).ShouldBeEmpty();
|
|
}
|
|
|
|
// ─── Hilfen ───
|
|
|
|
private async Task<IReadOnlyList<ManagedInstance>> RegisterAsync()
|
|
{
|
|
var register = new InstanceRegistry(
|
|
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
|
|
SupervisionOptions.Default);
|
|
|
|
await register.RefreshAsync();
|
|
return register.Instances;
|
|
}
|
|
|
|
private sealed class FakeUploader : IBackupUploader
|
|
{
|
|
public List<string> Hochgeladen { get; } = [];
|
|
public Exception? Fehler { get; set; }
|
|
public bool IsConfigured => true;
|
|
|
|
public Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct)
|
|
{
|
|
if (Fehler is not null) throw Fehler;
|
|
Hochgeladen.Add(localPath);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<string?> TestAsync(CancellationToken ct) => Task.FromResult<string?>(null);
|
|
}
|
|
|
|
/// <summary>Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt.</summary>
|
|
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
|
|
{
|
|
public DateTimeOffset Jetzt { get; set; } = start;
|
|
public override DateTimeOffset GetUtcNow() => Jetzt;
|
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
|
}
|
|
}
|