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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8552755bb6
commit
d2559c763a
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: Tool settings written from the WebUI — the desktop ToolSettingsView, without a
|
||||
/// window. The secrets go one way, and nothing the form does not touch may change.
|
||||
/// </summary>
|
||||
public sealed class ToolSettingsFormTests
|
||||
{
|
||||
/// <summary>Settings as they are after loading: values as JsonElement, as from the file.</summary>
|
||||
private static Dictionary<string, object?> Loaded(string json)
|
||||
=> JsonSerializer.Deserialize<Dictionary<string, object?>>(json)!;
|
||||
|
||||
[Fact]
|
||||
public void The_form_shows_no_secret()
|
||||
{
|
||||
var form = ToolSettingsForms.Describe("FTP", Loaded("""{"host":"ftp.example.org","password":"hunter2"}"""))!;
|
||||
|
||||
var password = form.Single(f => f.Name == "Password");
|
||||
password.Kind.ShouldBe(ToolSettingsForms.Secret);
|
||||
password.Value.ShouldBeNull();
|
||||
password.IsSet.ShouldBeTrue();
|
||||
JsonSerializer.Serialize(form).ShouldNotContain("hunter2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_connection_string_is_a_secret_even_without_a_password_mark()
|
||||
{
|
||||
// It carries the database password; ConfigSecrets protects it by name.
|
||||
var form = ToolSettingsForms.Describe("Database", Loaded("""{"connectionString":"Server=db;Password=x"}"""))!;
|
||||
|
||||
form.Single(f => f.Name == "ConnectionString").Kind.ShouldBe(ToolSettingsForms.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_secret_keeps_the_stored_one()
|
||||
{
|
||||
var (settings, error) = ToolSettingsForms.Apply("FTP", Loaded("""{"host":"old","password":"hunter2"}"""),
|
||||
new Dictionary<string, string?> { ["Host"] = "new", ["Password"] = "" });
|
||||
|
||||
error.ShouldBeNull();
|
||||
settings!["host"].ShouldBe("new");
|
||||
settings["password"]!.ToString().ShouldBe("hunter2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untouched_fields_stay_exactly_as_stored()
|
||||
{
|
||||
// allowedTables is a JSON array in the file, allowWrite an old key the class only reads,
|
||||
// "extra" a key no class knows. Saving another field must leave all three alone.
|
||||
var stored = Loaded("""{"type":"mysql","connectionString":"cs","allowedTables":["orders","stock"],"allowWrite":true,"extra":42}""");
|
||||
|
||||
var (settings, _) = ToolSettingsForms.Apply("Database", stored,
|
||||
new Dictionary<string, string?> { ["Type"] = "Postgres" });
|
||||
|
||||
settings!["type"].ShouldBe("postgres");
|
||||
JsonSerializer.Serialize(settings["allowedTables"]).ShouldBe("""["orders","stock"]""");
|
||||
JsonSerializer.Serialize(settings["allowWrite"]).ShouldBe("true");
|
||||
JsonSerializer.Serialize(settings["extra"]).ShouldBe("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_table_list_reads_back_as_names_not_as_json()
|
||||
{
|
||||
var form = ToolSettingsForms.Describe("Database", Loaded("""{"allowedTables":["orders","stock"]}"""))!;
|
||||
|
||||
form.Single(f => f.Name == "AllowedTables").Value.ShouldBe("orders,stock");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Port", "twenty-one")]
|
||||
[InlineData("Nonsense", "x")]
|
||||
public void A_bad_input_is_refused(string field, string value)
|
||||
{
|
||||
var (settings, error) = ToolSettingsForms.Apply("FTP", Loaded("{}"),
|
||||
new Dictionary<string, string?> { [field] = value });
|
||||
|
||||
settings.ShouldBeNull();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_enum_outside_its_values_is_refused()
|
||||
{
|
||||
var (settings, _) = ToolSettingsForms.Apply("Database", Loaded("{}"),
|
||||
new Dictionary<string, string?> { ["AccessLevel"] = "99" });
|
||||
|
||||
settings.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_through_the_agent_keeps_the_rights()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model")).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.SetToolEnabled("scout", "FTP", true).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.SetToolRights("scout", "FTP", ["list"], ["delete"]).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.AgentAdmin.ApplyToolSettingsForm("scout", "FTP",
|
||||
new Dictionary<string, string?> { ["Host"] = "ftp.example.org" }).Ok.ShouldBeTrue();
|
||||
|
||||
var tool = host.Host.AgentAdmin.Find("scout")!.Tools["FTP"];
|
||||
tool["host"].ShouldBe("ftp.example.org");
|
||||
tool.ShouldContainKey(Werkzeugrechte.ErlaubtSchluessel);
|
||||
tool.ShouldContainKey(Werkzeugrechte.VerbotenSchluessel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tool_without_a_form_has_none()
|
||||
=> ToolSettingsForms.Describe("Memory", []).ShouldBeNull();
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user