Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

284 lines
9.8 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>
/// The backups in the manager (#530): encrypted with the license key, once a
/// day, cleaned up, uploaded on request.
/// </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 _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<InstanceConfig>(File.ReadAllText(datei))!;
config.AutoBackupEnabled = automatic;
config.AutoBackupTime = timeOfDay;
config.BackupKeepCount = kept;
File.WriteAllText(datei, JsonSerializer.Serialize(config));
return path;
}
private async Task<ManagerBackups> 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 ───
/// <summary>
/// A prefix is not a boundary: the archives of <c>Test_2</c> do not belong to
/// <c>Test</c> — otherwise the cleanup of one throws away the backups of the other.
/// </summary>
[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<BackupException>(() =>
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]);
}
/// <summary>
/// A failed upload is a finding, but no reason to declare the backup
/// failed — it lies locally, after all.
/// </summary>
[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);
}
/// <summary>
/// Even across a restart of the manager. Counting only in memory would mean: whoever
/// restarts after the backup time gets a second backup.
/// </summary>
[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<IReadOnlyList<ManagedInstance>> 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<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>A clock in UTC, so "local" means the same everywhere in the test.</summary>
private sealed class FixedClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Jetzt;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}