- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming. - Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften). - ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert. - Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Die Regeln, die bis zum 2026-09-06 in den Avalonia-Ansichtsmodellen standen.
|
||||
///
|
||||
/// <para>Dass sie dort standen, war der Grund für diese Tests: Ein Ansichtsmodell hat
|
||||
/// keine Testabdeckung — das sagt der Oberflächen-Leitfaden ausdrücklich —, und mit der
|
||||
/// zweiten Oberfläche wären dieselben Regeln ein zweites Mal geschrieben worden. Jetzt
|
||||
/// gibt es sie einmal, und ab hier ist geprüft, dass beide Oberflächen dasselbe
|
||||
/// bekommen.</para>
|
||||
/// </summary>
|
||||
public sealed class AgentAdministrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Eine_doppelte_Agenten_ID_wird_abgelehnt()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
var ergebnis = host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
||||
|
||||
ergebnis.Ok.ShouldBeFalse();
|
||||
ergebnis.Message.ShouldContain("existiert bereits");
|
||||
host.Host.Instance.Agents.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Die_Pruefung_ignoriert_Gross_und_Kleinschreibung()
|
||||
{
|
||||
// Zwei Agenten, die sich nur darin unterscheiden, waeren auf der Zielplattform
|
||||
// zwei Ordner und fuer jeden Menschen derselbe Agent.
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("SCOUT", "Scout", "modell")).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_ID_wird_kein_Agent_angelegt()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent(" ", "Scout", "modell")).Ok.ShouldBeFalse();
|
||||
host.Host.Instance.Agents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_angelegter_Agent_ueberlebt_den_Neustart()
|
||||
{
|
||||
// Der Kern des Befunds: LoadInstanceConfig leert die Agentenliste und baut sie
|
||||
// aus den Ordnern Agents/Agent-* wieder auf. Ein Agent, der nur in
|
||||
// InstanceSettings.json stand, war nach dem naechsten Start weg — genau das tat
|
||||
// die Oberflaeche bis zum 2026-09-06.
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell", "Späher")).Ok.ShouldBeTrue();
|
||||
|
||||
var nachNeustart = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
|
||||
|
||||
nachNeustart.Agents.ShouldContain(a => a.AgentId == "scout" && a.DisplayName == "Scout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aenderungen_an_einem_Agenten_ueberleben_den_Neustart()
|
||||
{
|
||||
// Dieselbe Ursache aus der anderen Richtung: Bis zum 2026-09-06 rief niemand im
|
||||
// laufenden Betrieb SaveAgentSettings. Modell, Prompt und Budget standen nur in
|
||||
// InstanceSettings.json — es sah aus wie Speichern und war keines.
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "altes-modell"));
|
||||
|
||||
var agent = host.Host.AgentAdmin.Find("scout")!;
|
||||
agent.Model = "neues-modell";
|
||||
agent.Budget.DailyTokens = 12_345;
|
||||
agent.Identity = "# Identity: geändert";
|
||||
|
||||
host.Host.AgentAdmin.Save();
|
||||
|
||||
var nachNeustart = host.Host.Directories
|
||||
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
||||
.Single(a => a.AgentId == "scout");
|
||||
|
||||
nachNeustart.Model.ShouldBe("neues-modell");
|
||||
nachNeustart.Budget.DailyTokens.ShouldBe(12_345);
|
||||
nachNeustart.Identity.ShouldBe("# Identity: geändert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Werkzeug_bleibt_ueber_den_Neustart_zugelassen()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
||||
host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true);
|
||||
|
||||
host.Host.Directories
|
||||
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
||||
.Single(a => a.AgentId == "scout")
|
||||
.Tools.ShouldContainKey("FileRW");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jede_Aenderung_meldet_sich()
|
||||
{
|
||||
// Ohne diese Meldung erschiene ein in der Weboberflaeche angelegter Agent im
|
||||
// laufenden Fenster nicht — der ganze Grund, warum es das Ereignis gibt.
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
||||
|
||||
host.Changes.ShouldHaveSingleItem();
|
||||
host.Changes[0].Kind.ShouldBe(InstanceChangeKind.Agents);
|
||||
host.Changes[0].Id.ShouldBe("scout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_entfernter_Agent_kommt_nach_dem_Neustart_nicht_wieder()
|
||||
{
|
||||
// Die Gegenrichtung desselben Befunds: Nur aus der Liste zu streichen genuegte
|
||||
// nicht — der Ordner blieb liegen, und beim naechsten Start stand der Agent
|
||||
// wieder da.
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
||||
host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.Directories
|
||||
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
||||
.ShouldNotContain(a => a.AgentId == "scout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_entfernter_Agent_behaelt_seine_Arbeit()
|
||||
{
|
||||
// Umbenannt, nicht geloescht. Im Arbeitsordner steht seine Arbeit, und ein Klick
|
||||
// in einer Liste ist keine Entscheidung, sie wegzuwerfen.
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
|
||||
|
||||
var arbeitsdatei = Path.Combine(
|
||||
host.Host.AgentAdmin.Find("scout")!.AgentDir, "Workspace", "ergebnis.txt");
|
||||
|
||||
File.WriteAllText(arbeitsdatei, "wichtig");
|
||||
|
||||
host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue();
|
||||
|
||||
var agentsDir = Path.Combine(host.Host.InstancePath, "Agents");
|
||||
|
||||
Directory.GetDirectories(agentsDir, "Entfernt-*").ShouldHaveSingleItem();
|
||||
|
||||
Directory.GetFiles(agentsDir, "ergebnis.txt", SearchOption.AllDirectories)
|
||||
.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Werkzeug_zulassen_legt_eine_leere_Konfiguration_an()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("FileRW");
|
||||
host.Host.AgentAdmin.Find("scout")!.Tools["FileRW"].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Werkzeug_wegnehmen_entfernt_auch_seine_Einstellungen()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.AgentAdmin.SetToolSettings(
|
||||
"scout", "Mail", new Dictionary<string, object?> { ["host"] = "mail.example" });
|
||||
|
||||
host.Host.AgentAdmin.SetToolEnabled("scout", "Mail", enabled: false);
|
||||
|
||||
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldNotContainKey("Mail");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Einstellungen_zu_schreiben_heisst_das_Werkzeug_zu_wollen()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.AgentAdmin.SetToolSettings(
|
||||
"scout", "Mail", new Dictionary<string, object?> { ["host"] = "mail.example" });
|
||||
|
||||
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("Mail");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_unbekannter_Agent_liefert_eine_Antwort_und_keine_Ausnahme()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Remove("gibtsnicht").Ok.ShouldBeFalse();
|
||||
host.Host.AgentAdmin.SetToolEnabled("gibtsnicht", "FileRW", true).Ok.ShouldBeFalse();
|
||||
host.Changes.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class JobAdministrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ein_unlesbarer_Zeitplan_wird_abgelehnt()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
var ergebnis = host.Host.JobAdmin.Add(new JobDefinition(
|
||||
"scout", JobKind.AgentWakeup, Cron: "jeden zweiten Dienstag"));
|
||||
|
||||
ergebnis.Ok.ShouldBeFalse();
|
||||
ergebnis.Message.ShouldContain("nicht lesbar");
|
||||
host.Host.AgentAdmin.Find("scout")!.Scheduler.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_leerer_Zeitplan_ist_kein_Fehler()
|
||||
{
|
||||
// Leer heisst „nie" und ist eine gueltige Angabe. Nur unlesbar ist ein Fehler.
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Wakeup_laesst_sich_nicht_abschalten()
|
||||
{
|
||||
// SchedulerConfig hat keinen Schalter, und einen hier zu erfinden hiesse, dass
|
||||
// der Scanner ihn erst einmal lesen muesste. Sonst liefe ein „abgeschalteter"
|
||||
// Wakeup munter weiter.
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
|
||||
|
||||
var ergebnis = host.Host.JobAdmin.SetEnabled(
|
||||
"scout", JobAdministration.WakeupId("scout"), enabled: false);
|
||||
|
||||
ergebnis.Ok.ShouldBeFalse();
|
||||
ergebnis.Message.ShouldContain("Nur Tool-Jobs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Tool_Job_laesst_sich_abschalten_ohne_ihn_zu_verlieren()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.JobAdmin.Add(new JobDefinition(
|
||||
"scout", JobKind.ToolJob, ToolName: "Mail", JobTypeId: "PollJob", Cron: "0 * * * *"));
|
||||
|
||||
var job = host.Host.JobAdmin.List().Single();
|
||||
|
||||
host.Host.JobAdmin.SetEnabled("scout", job.JobId, enabled: false).Ok.ShouldBeTrue();
|
||||
|
||||
var danach = host.Host.JobAdmin.List().Single();
|
||||
danach.Enabled.ShouldBeFalse();
|
||||
danach.ToolName.ShouldBe("Mail");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Scanner_gilt_ein_Wakeup_als_nicht_aktiv()
|
||||
{
|
||||
// Er laeuft, solange der Scanner laeuft — der ist der einzige periodische
|
||||
// Treiber. Ohne ihn steht er, ohne dass jemand ihn abgeschaltet haette.
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
|
||||
|
||||
host.Host.Scanner.ShouldBeNull();
|
||||
host.Host.JobAdmin.List().Single().Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Der_naechste_Lauf_wird_aus_dem_Zeitplan_gerechnet()
|
||||
{
|
||||
var (next, valid) = JobAdministration.NextRun("0 * * * *");
|
||||
|
||||
valid.ShouldBeTrue();
|
||||
next.ShouldNotBeNull();
|
||||
next!.Value.ShouldBeGreaterThan(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_unlesbarer_Zeitplan_ist_von_einem_fehlenden_zu_unterscheiden()
|
||||
{
|
||||
JobAdministration.NextRun("").ShouldBe((null, true));
|
||||
JobAdministration.NextRun("kaputt").ShouldBe((null, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_entfernter_Wakeup_ist_weg_und_der_Agent_bleibt()
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
|
||||
|
||||
host.Host.JobAdmin.Remove("scout", JobAdministration.WakeupId("scout"), JobKind.AgentWakeup)
|
||||
.Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.AgentAdmin.Find("scout").ShouldNotBeNull();
|
||||
host.Host.JobAdmin.List().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ServiceAdministrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Die_eingebauten_Dienste_kommen_genau_einmal_dazu()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.ServiceAdmin.EnsureBuiltIns();
|
||||
var nachDemErsten = host.Host.Instance.Services.Count;
|
||||
|
||||
host.Host.ServiceAdmin.EnsureBuiltIns();
|
||||
|
||||
nachDemErsten.ShouldBe(BuiltInServices.CreateDefaults().Count);
|
||||
host.Host.Instance.Services.Count.ShouldBe(nachDemErsten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_abgeschalteter_eingebauter_Dienst_bleibt_abgeschaltet()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.ServiceAdmin.EnsureBuiltIns();
|
||||
|
||||
var dienst = host.Host.Instance.Services.First(s => s.BuiltIn);
|
||||
host.Host.ServiceAdmin.SetEnabled(dienst.ServiceId, enabled: false);
|
||||
|
||||
host.Host.ServiceAdmin.EnsureBuiltIns();
|
||||
|
||||
host.Host.ServiceAdmin.Find(dienst.ServiceId)!.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Dienst_ohne_Namen_wird_abgelehnt()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.ServiceAdmin.Add(new NewService(" ", "HTTP", 8080)).Ok.ShouldBeFalse();
|
||||
host.Host.Instance.Services.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ein <see cref="AppHost"/> ohne Engine, Scanner und Netz — genug für die
|
||||
/// Verwaltungsdienste.
|
||||
///
|
||||
/// <para>Möglich ist das, weil <c>AppHost.StartAsync</c> nur <em>ein</em> Weg ist, einen
|
||||
/// aufzubauen: Die Eigenschaften sind <c>required init</c>, mehr braucht es nicht. Was
|
||||
/// hier fehlt — <c>Engine</c>, <c>Scanner</c>, <c>Staging</c> — ist auch im Betrieb
|
||||
/// regelmäßig null, und genau dieses Verhalten soll geprüft werden.</para>
|
||||
/// </summary>
|
||||
internal sealed class TestHost : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public AppHost Host { get; }
|
||||
|
||||
/// <summary>Jede Änderung, die über <c>SaveInstance</c> gelaufen ist.</summary>
|
||||
public List<InstanceChange> Changes { get; } = [];
|
||||
|
||||
public TestHost(params AgentConfig[] agents)
|
||||
{
|
||||
_root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "host-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
var instancesDir = Path.Combine(_root, "Instances");
|
||||
var instancePath = Path.Combine(instancesDir, "Test");
|
||||
|
||||
Directory.CreateDirectory(instancePath);
|
||||
|
||||
var instance = new InstanceConfig { InstanceName = "Test" };
|
||||
instance.Agents.AddRange(agents);
|
||||
|
||||
Host = new AppHost
|
||||
{
|
||||
Settings = new SettingsManager(_root),
|
||||
Directories = new InstanceDirectoryManager(instancesDir),
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = Path.Combine(_root, "Logs"),
|
||||
LoggerFactory = NullLoggerFactory.Instance,
|
||||
Tools = new ToolRegistry()
|
||||
};
|
||||
|
||||
Host.InstanceChanged += Changes.Add;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner ist kein Grund, einen Lauf rot zu färben. */ }
|
||||
}
|
||||
|
||||
public static AgentConfig Agent(string agentId, string displayName = "")
|
||||
=> new()
|
||||
{
|
||||
AgentId = agentId,
|
||||
DisplayName = displayName.Length == 0 ? agentId : displayName,
|
||||
Model = "anthropic/claude-sonnet-4-5"
|
||||
};
|
||||
}
|
||||
@@ -159,6 +159,97 @@ public sealed class BackupServiceTests : IDisposable
|
||||
SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Umzug auf einen anderen Rechner — der Migrationspfad aus #190.
|
||||
///
|
||||
/// <para>Die Tests darüber sichern und stellen im selben Lauf wieder her und
|
||||
/// benutzen dabei denselben Schlüssel. Sie können damit gar nicht zeigen, worauf es
|
||||
/// beim Umzug ankommt: dass die Werte auch dann lesbar sind, wenn der Schlüssel der
|
||||
/// Quellmaschine <b>nicht</b> mitkommt. Genau das ist der Fall Windows → Linux —
|
||||
/// die bestehenden Werte sind DPAPI-verschlüsselt, und
|
||||
/// <c>SecretProtector.UnprotectV1</c> wirft dafür auf Linux bewusst.</para>
|
||||
///
|
||||
/// <para>Hier wechselt der Schlüssel deshalb mitten im Test. Ohne den Wechsel wäre
|
||||
/// der Test grün, ohne etwas zu belegen.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Der_Umzug_auf_einen_anderen_Rechner_traegt()
|
||||
{
|
||||
var zielMaschine = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
try
|
||||
{
|
||||
// Maschine 1: Werte liegen unter dem hiesigen Schlüssel.
|
||||
await BuildInstanceAsync();
|
||||
var schluesselDerQuelle = File.ReadAllBytes(SecretKeyStore.KeyFilePath);
|
||||
|
||||
await _service.CreateAsync(_instanceDir, ZipPath,
|
||||
new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "umzug" });
|
||||
|
||||
// Maschine 2: anderer Rechner, anderer Schlüssel. Der Schlüssel reist nicht
|
||||
// mit — er liegt bewusst außerhalb des Instanzverzeichnisses.
|
||||
SecretKeyStore.UseDirectory(zielMaschine);
|
||||
|
||||
await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "umzug" });
|
||||
|
||||
File.ReadAllBytes(SecretKeyStore.KeyFilePath)
|
||||
.ShouldNotBe(schluesselDerQuelle, "sonst wäre es derselbe Rechner");
|
||||
|
||||
var settings = JsonDocument.Parse(
|
||||
File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json")));
|
||||
|
||||
var stored = settings.RootElement.GetProperty("openRouterApiKey").GetString();
|
||||
SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain);
|
||||
|
||||
// Auch die verschachtelten Werte, nicht nur der offensichtliche oben.
|
||||
var agent = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "AgentSettings.json")));
|
||||
|
||||
var password = agent.RootElement
|
||||
.GetProperty("tools").GetProperty("Mail").GetProperty("password").GetString();
|
||||
|
||||
SecretProtector.Unprotect(password).ShouldBe(MailPasswordPlain);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Ein eigenes Verzeichnis für den nächsten Test der Sammlung: Das der
|
||||
// Quellmaschine steht hier nicht mehr zur Verfügung, und der echte
|
||||
// Benutzerschlüssel darf es erst recht nicht sein.
|
||||
SecretKeyStore.UseDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
|
||||
try { Directory.Delete(zielMaschine, recursive: true); } catch { /* Nebensache */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_die_Passphrase_ist_der_Umzug_verloren()
|
||||
{
|
||||
// Die Kehrseite, und der Grund, warum die Passphrase beim Sichern angeboten
|
||||
// werden muss: Ohne sie ist ein Backup auf der Zielmaschine wertlos, was die
|
||||
// Zugangsdaten angeht. Das ist kein Fehler, sondern der Preis dafür, dass der
|
||||
// Schlüssel nicht mitreist — aber es muss auffallen, statt still zu geschehen.
|
||||
var zielMaschine = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
try
|
||||
{
|
||||
await BuildInstanceAsync();
|
||||
|
||||
await _service.CreateAsync(_instanceDir, ZipPath,
|
||||
new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "umzug" });
|
||||
|
||||
SecretKeyStore.UseDirectory(zielMaschine);
|
||||
|
||||
await Should.ThrowAsync<Exception>(
|
||||
() => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "falsch" }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SecretKeyStore.UseDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
|
||||
try { Directory.Delete(zielMaschine, recursive: true); } catch { /* Nebensache */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auch_verschachtelte_Zugangsdaten_in_Tool_Konfigurationen_kommen_zurueck()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Web.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Web\ClawdDotNet.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,96 @@
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Web.Contracts;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Kein Zugangsdatum verlässt die API.
|
||||
///
|
||||
/// <para>Das ist die Prüfung, die man nicht von Hand machen will: Werkzeug-Einstellungen
|
||||
/// sind ein freies Wörterbuch, und ob darin ein Passwort steckt, entscheidet der Name des
|
||||
/// Schlüssels. Ein <c>return agent.Tools;</c> hätte den ganzen Baum samt Passwörtern ins
|
||||
/// Netz geschickt — die Sorte Fehler, die niemandem auffällt, weil alles funktioniert.</para>
|
||||
/// </summary>
|
||||
public sealed class SecretMaskingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ein_Passwort_kommt_nicht_heraus()
|
||||
{
|
||||
var einstellungen = new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "mail.example",
|
||||
["port"] = 993,
|
||||
["password"] = "sehr-geheim"
|
||||
};
|
||||
|
||||
var dto = Mapping.ToDto("Mail", einstellungen);
|
||||
|
||||
// Keys.ShouldContain statt ShouldContainKey: Die DTOs geben
|
||||
// IReadOnlyDictionary heraus, und Shouldlys Woerterbuch-Erweiterungen sitzen
|
||||
// auf IDictionary.
|
||||
dto.Values.Keys.ShouldContain("host");
|
||||
dto.Values.Keys.ShouldContain("port");
|
||||
dto.Values.Keys.ShouldNotContain("password");
|
||||
|
||||
// Was herauskommt, ist die Auskunft „da steht etwas" — mehr braucht eine
|
||||
// Anzeige nicht, und mehr darf sie nicht wissen.
|
||||
dto.Secrets["password"].ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_leeres_Geheimnis_gilt_als_nicht_gesetzt()
|
||||
{
|
||||
var dto = Mapping.ToDto("Mail", new Dictionary<string, object?>
|
||||
{
|
||||
["password"] = "",
|
||||
["apiKey"] = null
|
||||
});
|
||||
|
||||
dto.Secrets["password"].ShouldBeFalse();
|
||||
dto.Secrets["apiKey"].ShouldBeFalse();
|
||||
dto.Values.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("password")]
|
||||
[InlineData("apiKey")]
|
||||
[InlineData("api_key")]
|
||||
[InlineData("token")]
|
||||
[InlineData("accessToken")]
|
||||
[InlineData("botToken")]
|
||||
[InlineData("clientSecret")]
|
||||
[InlineData("connectionString")]
|
||||
[InlineData("apiHash")]
|
||||
[InlineData("webUiToken")]
|
||||
public void Jeder_bekannte_Geheimnisname_wird_maskiert(string schluessel)
|
||||
{
|
||||
// Die Liste kommt aus ConfigSecrets und nicht aus einer zweiten hier: Eine
|
||||
// zweite waere die, die beim naechsten neuen Werkzeug vergessen wird.
|
||||
ConfigSecrets.IsSecretKey(schluessel).ShouldBeTrue();
|
||||
|
||||
Mapping.ToDto("X", new Dictionary<string, object?> { [schluessel] = "geheim" })
|
||||
.Values.Keys.ShouldNotContain(schluessel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Die_Schreibweise_des_Schluessels_spielt_keine_Rolle()
|
||||
{
|
||||
Mapping.ToDto("X", new Dictionary<string, object?> { ["PASSWORD"] = "geheim" })
|
||||
.Values.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_serialisiertes_DTO_enthaelt_das_Geheimnis_nicht()
|
||||
{
|
||||
// Der Test, der wirklich zaehlt: nicht die Struktur, sondern das, was ueber die
|
||||
// Leitung geht.
|
||||
var dto = Mapping.ToDto("Mail", new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "mail.example",
|
||||
["password"] = "sehr-geheim"
|
||||
});
|
||||
|
||||
System.Text.Json.JsonSerializer.Serialize(dto).ShouldNotContain("sehr-geheim");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Die Weboberfläche macht aus einer lokalen Anwendung einen Netzwerkdienst, der Agenten
|
||||
/// mit Datei-, Mail-, FTP- und Datenbankzugriff steuert. Was hier geprüft wird, sind
|
||||
/// keine Feinheiten der Konfiguration, sondern die Bedingungen, unter denen sie
|
||||
/// überhaupt hochkommen darf.
|
||||
/// </summary>
|
||||
public sealed class WebUiOptionsTests
|
||||
{
|
||||
private static InstanceConfig Instanz(
|
||||
bool enabled = true,
|
||||
string binding = "loopback",
|
||||
string token = "ein-hinreichend-langes-token",
|
||||
int port = 8080)
|
||||
=> new()
|
||||
{
|
||||
WebUiEnabled = enabled,
|
||||
WebUiBinding = binding,
|
||||
WebUiToken = token,
|
||||
WebServerPort = port
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Sie_ist_in_der_Vorbelegung_aus()
|
||||
{
|
||||
// Wer einen Netzwerkdienst will, schaltet ihn ein. Nicht andersherum.
|
||||
new InstanceConfig().WebUiEnabled.ShouldBeFalse();
|
||||
new InstanceConfig().WebUiBinding.ShouldBe("loopback");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Abgeschaltet_ist_kein_Fehler()
|
||||
{
|
||||
WebUiOptions.TryCreate(Instanz(enabled: false), out var problem).ShouldBeNull();
|
||||
problem.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Token_startet_sie_nicht_einmal_auf_Loopback()
|
||||
{
|
||||
// Auf einer Maschine, auf der Agenten Browser-Werkzeuge bedienen, ist
|
||||
// „localhost" keine Vertrauensgrenze.
|
||||
WebUiOptions.TryCreate(Instanz(token: ""), out var problem).ShouldBeNull();
|
||||
problem.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alle_Adressen_ohne_Token_werden_abgelehnt_und_nicht_bemaengelt()
|
||||
{
|
||||
var options = WebUiOptions.TryCreate(Instanz(binding: "any", token: ""), out var problem);
|
||||
|
||||
options.ShouldBeNull();
|
||||
problem.ShouldNotBeNull();
|
||||
problem!.ShouldContain("So wird sie nicht gestartet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_zu_kurzes_Token_zaehlt_nicht()
|
||||
{
|
||||
WebUiOptions.TryCreate(Instanz(token: "kurz"), out var problem).ShouldBeNull();
|
||||
problem.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loopback_bindet_an_127_0_0_1()
|
||||
{
|
||||
var options = WebUiOptions.TryCreate(Instanz(port: 9001), out _);
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options!.Url.ShouldBe("http://127.0.0.1:9001");
|
||||
options.LoopbackOnly.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alles_ausser_any_bleibt_Loopback()
|
||||
{
|
||||
// Ein Tippfehler in der Instanzdatei darf die Oberflaeche nicht ins Netz stellen.
|
||||
WebUiOptions.TryCreate(Instanz(binding: "alle"), out _)!.LoopbackOnly.ShouldBeTrue();
|
||||
WebUiOptions.TryCreate(Instanz(binding: ""), out _)!.LoopbackOnly.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mit_Token_darf_sie_an_alle_Adressen()
|
||||
{
|
||||
var options = WebUiOptions.TryCreate(Instanz(binding: "ANY"), out _);
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options!.LoopbackOnly.ShouldBeFalse();
|
||||
options.Url.ShouldStartWith("http://0.0.0.0:");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(70000)]
|
||||
public void Ein_unmoeglicher_Port_wird_abgelehnt(int port)
|
||||
{
|
||||
WebUiOptions.TryCreate(Instanz(port: port), out var problem).ShouldBeNull();
|
||||
problem.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Das_Token_steht_geschuetzt_in_der_Instanzdatei()
|
||||
{
|
||||
// Ohne den Eintrag in ConfigSecrets staende es im Klartext neben dem
|
||||
// geschuetzten OpenRouter-Schluessel.
|
||||
Core.Security.ConfigSecrets.IsSecretKey("webUiToken").ShouldBeTrue();
|
||||
|
||||
var config = Instanz(token: "ein-hinreichend-langes-token");
|
||||
Core.Security.ConfigSecrets.Protect(config);
|
||||
|
||||
config.WebUiToken.ShouldNotBe("ein-hinreichend-langes-token");
|
||||
|
||||
Core.Security.ConfigSecrets.Unprotect(config);
|
||||
config.WebUiToken.ShouldBe("ein-hinreichend-langes-token");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user