Files
RichardandClaude Opus 5.5 d2559c763a Stufe 1 (#764): Werkzeugeinstellungen, Sicherungszeitplan und Import als neue Instanz
- Werkzeugformulare aus den typisierten Einstellungsklassen (L8): Geheimnisse
  nur schreibend, nur geaenderte Schluessel werden ersetzt, Rechte bleiben
- Database.allowedTables las ein JSON-Array als rohen Text und haette es beim
  naechsten Speichern zerlegt
- Automatische Sicherung der Instanz in der Sicherungsansicht (L10)
- Sicherung hochladen und als neue Instanz einspielen (L11): Pfade der alten
  Maschine werden zurueckgesetzt, eine Kopie neben dem Original bekommt eine
  eigene ID, Autostart aus; Upload bis 4 GB
- api.js schickte eine Datei als "{}"; Dialoge schliessen beim Seitenwechsel

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

170 lines
6.7 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>
/// #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.
/// </summary>
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);
}
/// <summary>An instance with paths of "another machine" and autostart on, backed up and then removed.</summary>
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<InstanceConfig>(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();
}
}