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; /// /// The backups in the manager (#530): encrypted with the license key, once a /// day, cleaned up, uploaded on request. /// 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 _directories; private readonly ManagerSettingsStore _settings; private readonly FakeUploader _upload = new(); private readonly FixedClock _clock = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero)); public ManagerBackupsTests() { Directory.CreateDirectory(_root); _directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); _settings = new ManagerSettingsStore(_root); _settings.Settings.LicenseKey = Lizenz; } public async ValueTask DisposeAsync() { await Task.CompletedTask; ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); try { Directory.Delete(_root, recursive: true); } catch { /* A leftover test folder does not turn a run red. */ } } private string Instance(string name, bool automatic = true, string timeOfDay = "03:00", int kept = 14) { var path = _directories.CreateInstance(name); var datei = Path.Combine(path, "InstanceSettings.json"); var config = JsonSerializer.Deserialize(File.ReadAllText(datei))!; config.AutoBackupEnabled = automatic; config.AutoBackupTime = timeOfDay; config.BackupKeepCount = kept; File.WriteAllText(datei, JsonSerializer.Serialize(config)); return path; } private async Task ServiceAsync(IBackupUploader? upload = null) { var register = new InstanceRegistry( _directories, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance, SupervisionOptions.Default); await register.RefreshAsync(); return new ManagerBackups( _settings, register, upload ?? _upload, NullLogger.Instance, _clock); } private static string[] Backups(string path) { var folder = Path.Combine(path, "Backups"); return Directory.Exists(folder) ? Directory.GetFiles(folder) : []; } // ─── Names ─── /// /// A prefix is not a boundary: the archives of Test_2 do not belong to /// Test — otherwise the cleanup of one throws away the backups of the other. /// [Fact] public void Overlapping_instance_names_are_not_confused() { var ownOnes = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0)); var foreignOnes = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0)); BackupNaming.BelongsTo(ownOnes, "Test").ShouldBeTrue(); BackupNaming.BelongsTo(foreignOnes, "Test").ShouldBeFalse(); BackupNaming.BelongsTo(foreignOnes, "Test_2").ShouldBeTrue(); } [Theory] [InlineData("backup_Alpha_2026-09-11_0300.zip")] // daily backup of the instance [InlineData("backup_Alpha_20260911_030000.zip")] // first administration view [InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager public void Older_spellings_are_recognised(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 Foreign_files_belong_to_nobody(string name) { BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse(); } // ─── Backing up ─── [Fact] public async Task Without_license_key_no_backup_is_created() { var path = Instance("Alpha"); _settings.Settings.LicenseKey = ""; await using var service = await ServiceAsync(); var instance = (await RegisterAsync()).Single(); await Should.ThrowAsync(() => service.CreateAsync(instance, false, true, CancellationToken.None)); Backups(path).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise."); } [Fact] public async Task A_backup_is_encrypted_and_uploaded() { var path = Instance("Alpha"); await using var service = await ServiceAsync(); var instance = (await RegisterAsync()).Single(); var result = await service.CreateAsync(instance, false, true, CancellationToken.None); LicenseBackupCipher.IsEncrypted(result.Path).ShouldBeTrue(); LicenseBackupCipher.MatchesKey(result.Path, Lizenz).ShouldBeTrue(); result.Uploaded.ShouldBeTrue(); _upload.Hochgeladen.ShouldBe([result.Path]); } /// /// A failed upload is a finding, but no reason to declare the backup /// failed — it lies locally, after all. /// [Fact] public async Task A_failed_upload_keeps_the_backup() { Instance("Alpha"); _upload.Fehler = new IOException("Server nicht erreichbar"); await using var service = await ServiceAsync(); var instance = (await RegisterAsync()).Single(); var result = await service.CreateAsync(instance, false, true, CancellationToken.None); File.Exists(result.Path).ShouldBeTrue(); result.Uploaded.ShouldBeFalse(); result.UploadProblem.ShouldBe("Server nicht erreichbar"); } [Fact] public async Task Only_the_own_instance_is_cleaned_up() { var folder = Path.Combine(_root, "ablage"); Directory.CreateDirectory(folder); for (var tag = 1; tag <= 5; tag++) { var time = new DateTime(2026, 9, tag, 3, 0, 0); File.WriteAllText(Path.Combine(folder, BackupNaming.FileNameFor("Test", time)), ""); File.WriteAllText(Path.Combine(folder, BackupNaming.FileNameFor("Test_2", time)), ""); } await using var service = await ServiceAsync(); service.Rotate(folder, "Test", keep: 2); var names = Directory.GetFiles(folder).Select(Path.GetFileName).ToList(); names.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2); names.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt."); names.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0))); } // ─── The daily timer ─── [Fact] public async Task Before_the_time_nothing_happens() { var path = Instance("Alpha", timeOfDay: "03:00"); await using var service = await ServiceAsync(); await service.TickAsync(); // 02:00 Backups(path).ShouldBeEmpty(); } [Fact] public async Task Once_a_day_and_not_more_often() { var path = Instance("Alpha", timeOfDay: "03:00"); await using var service = await ServiceAsync(); _clock.Jetzt = _clock.Jetzt.AddHours(1.5); // 03:30 await service.TickAsync(); _clock.Jetzt = _clock.Jetzt.AddMinutes(30); // 04:00 await service.TickAsync(); Backups(path).Length.ShouldBe(1); } /// /// Even across a restart of the manager. Counting only in memory would mean: whoever /// restarts after the backup time gets a second backup. /// [Fact] public async Task A_manager_restart_creates_no_second_backup() { var path = Instance("Alpha", timeOfDay: "03:00"); _clock.Jetzt = _clock.Jetzt.AddHours(1.5); await using (var firstOne = await ServiceAsync()) await firstOne.TickAsync(); _clock.Jetzt = _clock.Jetzt.AddHours(1); await using (var secondOne = await ServiceAsync()) await secondOne.TickAsync(); Backups(path).Length.ShouldBe(1); } [Fact] public async Task Without_switch_no_automatic_backup() { var path = Instance("Alpha", automatic: false); _clock.Jetzt = _clock.Jetzt.AddHours(5); await using var service = await ServiceAsync(); await service.TickAsync(); Backups(path).ShouldBeEmpty(); } // ─── Helpers ─── private async Task> RegisterAsync() { var register = new InstanceRegistry( _directories, 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); } /// A clock in UTC, so "local" means the same everywhere in the test. private sealed class FixedClock(DateTimeOffset start) : TimeProvider { public DateTimeOffset Jetzt { get; set; } = start; public override DateTimeOffset GetUtcNow() => Jetzt; public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; } }