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; /// /// #764: A backup uploaded in the WebUI becomes a new instance — the move to a fresh machine, /// which only the desktop restore window could do before. /// public sealed class BackupImportTests : IAsyncDisposable { private const string License = "CLAWD-TEST-0000-1111-2222"; private const string ForeignLicense = "CLAWD-TEST-9999-8888-7777"; private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "mimp-" + Guid.NewGuid().ToString("N")[..8]); private readonly InstanceDirectoryManager _directories; private readonly ManagerSettingsStore _settings; public BackupImportTests() { Directory.CreateDirectory(_root); _directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); _settings = new ManagerSettingsStore(_root); _settings.Settings.LicenseKey = License; } 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 async Task<(ManagerBackups Backups, InstanceRegistry Registry)> ServiceAsync() { var registry = new InstanceRegistry( _directories, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance, SupervisionOptions.Default); await registry.RefreshAsync(); return (new ManagerBackups(_settings, registry, null, NullLogger.Instance), registry); } /// An instance with paths of "another machine" and autostart on, backed up and then removed. private async Task<(string Archive, string InstanceId)> BackupOfAnInstanceAsync(string name, bool removeOriginal = true) { var path = _directories.CreateInstance(name); var file = Path.Combine(path, "InstanceSettings.json"); var config = JsonSerializer.Deserialize(File.ReadAllText(file))!; config.AutostartByManager = true; config.LogDirectory = Path.Combine(_root, "elsewhere", "logs"); config.BackupDirectory = Path.Combine(_root, "elsewhere", "backups"); File.WriteAllText(file, JsonSerializer.Serialize(config)); var (backups, registry) = await ServiceAsync(); await using (backups) await using (registry) { var instance = registry.Instances.Single(i => PathEquals(i.Directory, path)); var outcome = await backups.CreateAsync(instance, false, true, CancellationToken.None); var archive = Path.Combine(_root, "upload-" + Guid.NewGuid().ToString("N") + ".bak"); File.Copy(outcome.Path, archive); if (removeOriginal) { await registry.DisposeAsync(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); Directory.Delete(path, recursive: true); } return (archive, config.InstanceId); } } private static bool PathEquals(string a, string b) => ClawdDotNet.Core.Storage.PathBoundary.Comparer.Equals(Path.GetFullPath(a), Path.GetFullPath(b)); [Fact] public async Task A_backup_becomes_a_new_instance_with_its_own_paths() { var (archive, id) = await BackupOfAnInstanceAsync("Alpha"); var (backups, registry) = await ServiceAsync(); await using var _ = backups; await using var __ = registry; var result = await backups.ImportAsNewInstanceAsync(archive, "Moved", null, null); result.Ok.ShouldBeTrue(result.Message); var config = _directories.LoadInstanceConfig(result.Directory!); config.InstanceName.ShouldBe("Moved"); config.InstanceId.ShouldBe(id); // a move keeps its identity PathEquals(config.WorkingDirectory, result.Directory!).ShouldBeTrue(); config.LogDirectory.ShouldBeEmpty(); config.BackupDirectory.ShouldBeEmpty(); config.AutostartByManager.ShouldBeFalse(); registry.Instances.ShouldContain(i => PathEquals(i.Directory, result.Directory!)); } [Fact] public async Task A_copy_next_to_its_original_gets_a_new_id() { // Two instances with one id would report as one and share one reporting token. var (archive, id) = await BackupOfAnInstanceAsync("Alpha", removeOriginal: false); var (backups, registry) = await ServiceAsync(); await using var _ = backups; await using var __ = registry; var result = await backups.ImportAsNewInstanceAsync(archive, "Alpha-Copy", null, null); result.Ok.ShouldBeTrue(result.Message); var config = _directories.LoadInstanceConfig(result.Directory!); config.InstanceId.ShouldNotBe(id); config.Watchdog.AgentToken.ShouldBeEmpty(); } [Fact] public async Task An_existing_instance_is_never_overwritten() { var (archive, _) = await BackupOfAnInstanceAsync("Alpha"); _directories.CreateInstance("Taken"); var (backups, registry) = await ServiceAsync(); await using var a = backups; await using var b = registry; (await backups.ImportAsNewInstanceAsync(archive, "Taken", null, null)).Ok.ShouldBeFalse(); } [Fact] public async Task A_foreign_backup_asks_for_its_key_and_leaves_nothing_behind() { var (archive, _) = await BackupOfAnInstanceAsync("Alpha"); _settings.Settings.LicenseKey = ForeignLicense; // now this is "another installation" var (backups, registry) = await ServiceAsync(); await using var a = backups; await using var b = registry; var refused = await backups.ImportAsNewInstanceAsync(archive, "Moved", null, null); refused.KeyNeeded.ShouldBeTrue(); Directory.Exists(_directories.GetInstancePath("Moved")).ShouldBeFalse(); var accepted = await backups.ImportAsNewInstanceAsync(archive, "Moved", License, null); accepted.Ok.ShouldBeTrue(accepted.Message); } [Fact] public async Task A_broken_file_leaves_no_half_instance() { var junk = Path.Combine(_root, "junk.bak"); await File.WriteAllBytesAsync(junk, [1, 2, 3, 4, 5]); var (backups, registry) = await ServiceAsync(); await using var a = backups; await using var b = registry; (await backups.ImportAsNewInstanceAsync(junk, "Broken", null, null)).Ok.ShouldBeFalse(); Directory.Exists(_directories.GetInstancePath("Broken")).ShouldBeFalse(); } }