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; /// /// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am /// Tag, aufgeräumt, auf Wunsch hochgeladen. /// 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(File.ReadAllText(datei))!; config.AutoBackupEnabled = automatisch; config.AutoBackupTime = uhrzeit; config.BackupKeepCount = behalten; File.WriteAllText(datei, JsonSerializer.Serialize(config)); return pfad; } private async Task 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 ─── /// /// Ein Präfix ist keine Grenze: Die Archive von Test_2 gehören nicht zu /// Test — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg. /// [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(() => 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]); } /// /// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für /// misslungen zu erklären — sie liegt ja lokal. /// [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); } /// /// 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. /// [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> 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 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 TestAsync(CancellationToken ct) => Task.FromResult(null); } /// Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt. private sealed class FesteUhr(DateTimeOffset start) : TimeProvider { public DateTimeOffset Jetzt { get; set; } = start; public override DateTimeOffset GetUtcNow() => Jetzt; public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; } }