Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)

- 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:
Richard
2026-09-07 21:56:02 +02:00
parent cffd6f4b77
commit 1e9cb789c8
64 changed files with 10543 additions and 554 deletions
@@ -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");
}
}