feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -1,6 +1,7 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Security;
@@ -9,9 +10,29 @@ namespace ClawdDotNet.Core.Tests.Security;
/// S7 aus der Bestandsaufnahme: OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen
/// samt Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in
/// den JSON-Dateien. Wer die Dateien lesen konnte, hatte alle Zugänge.
///
/// Mit der Linux-Portierung kam <c>enc:v2</c> dazu (AES-GCM statt DPAPI). Die Tests
/// laufen deshalb gegen einen eigenen Schlüssel in einem Wegwerfverzeichnis — sonst
/// würden sie den echten Benutzerschlüssel anlegen oder lesen.
/// </summary>
public sealed class SecretProtectorTests
[Collection(SecretKeyCollection.Name)]
public sealed class SecretProtectorTests : IDisposable
{
private readonly string _keyDir;
public SecretProtectorTests()
{
_keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
SecretKeyStore.UseDirectory(_keyDir);
}
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
try { Directory.Delete(_keyDir, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
[Fact]
public void Ein_verschluesselter_Wert_laesst_sich_wieder_lesen()
{
@@ -75,6 +96,9 @@ public sealed class SecretProtectorTests
// Klartext auszugeben würde einen unbrauchbaren Schlüssel an die API schicken.
Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v1:das-ist-kein-gueltiger-block"));
Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v2:das-ist-kein-gueltiger-block"));
}
[Fact]
@@ -84,10 +108,120 @@ public sealed class SecretProtectorTests
SecretProtector.Unprotect(SecretProtector.Protect(secret)).ShouldBe(secret);
}
// ═══════════════════════════════════════════════════════════
// Plattformübergreifendes Format (enc:v2)
// ═══════════════════════════════════════════════════════════
[Fact]
public void Neue_Werte_werden_im_plattformuebergreifenden_Format_geschrieben()
{
// Der Kern der Portierung: Unter Linux gab die vorige Fassung hier den Klartext
// zurück. Auf einem Server, der gesichert wird, war das schlechter als nichts.
var value = SecretProtector.Protect("sk-or-v1-geheim");
value.ShouldStartWith("enc:v2:");
}
[Fact]
public void Dieselbe_Eingabe_ergibt_zweimal_verschiedene_Ausgaben()
{
// AES-GCM mit zufälligem Nonce: Aus gleichen Werten dürfen keine gleichen
// Blöcke werden, sonst verrät die Konfigurationsdatei, wo dasselbe Passwort
// mehrfach benutzt wird.
var a = SecretProtector.Protect("dasselbe-passwort");
var b = SecretProtector.Protect("dasselbe-passwort");
a.ShouldNotBe(b);
SecretProtector.Unprotect(a).ShouldBe("dasselbe-passwort");
SecretProtector.Unprotect(b).ShouldBe("dasselbe-passwort");
}
[Fact]
public void Ein_veraenderter_Block_wird_erkannt_und_nicht_entschluesselt()
{
// Der Zweck der Authentifizierung in AES-GCM: Ein manipulierter Block darf
// keinen halb geratenen Klartext ergeben.
var value = SecretProtector.Protect("sk-or-v1-geheim")!;
var payload = Convert.FromBase64String(value["enc:v2:".Length..]);
payload[^1] ^= 0xFF;
var tampered = "enc:v2:" + Convert.ToBase64String(payload);
Should.Throw<SecretProtectionException>(() => SecretProtector.Unprotect(tampered));
}
[Fact]
public void Ein_fremder_Schluessel_kann_den_Wert_nicht_lesen()
{
// Genau die Eigenschaft, die den Schutz ausmacht: Die Konfigurationsdatei allein
// nützt auf einem anderen Rechner nichts, weil der Schlüssel nicht mitreist.
var value = SecretProtector.Protect("sk-or-v1-geheim");
var fremd = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
try
{
SecretKeyStore.UseDirectory(fremd);
Should.Throw<SecretProtectionException>(() => SecretProtector.Unprotect(value));
}
finally
{
SecretKeyStore.UseDirectory(_keyDir);
try { Directory.Delete(fremd, recursive: true); } catch { }
}
}
[Fact]
public void Der_Schluessel_wird_einmal_angelegt_und_dann_wiederverwendet()
{
var first = SecretProtector.Protect("geheim");
var keyBytes = File.ReadAllBytes(SecretKeyStore.KeyFilePath);
var second = SecretProtector.Protect("noch-geheimer");
File.ReadAllBytes(SecretKeyStore.KeyFilePath).ShouldBe(keyBytes);
SecretProtector.Unprotect(first).ShouldBe("geheim");
SecretProtector.Unprotect(second).ShouldBe("noch-geheimer");
}
[LinuxFact]
public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar()
{
SecretProtector.Protect("geheim");
File.GetUnixFileMode(SecretKeyStore.KeyFilePath)
.ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
[LinuxFact]
public void Ein_DPAPI_Wert_aus_einer_Windows_Instanz_wird_mit_Begruendung_abgewiesen()
{
// Der Umzugsfall. Ihn als Klartext durchzureichen würde einen unbrauchbaren
// Schlüssel an die API schicken — die Meldung muss sagen, was zu tun ist.
var ex = Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4])));
ex.Message.ShouldContain("neu eingetragen");
}
}
public sealed class ConfigSecretsTests
[Collection(SecretKeyCollection.Name)]
public sealed class ConfigSecretsTests : IDisposable
{
private readonly string _keyDir;
public ConfigSecretsTests()
{
_keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
SecretKeyStore.UseDirectory(_keyDir);
}
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
try { Directory.Delete(_keyDir, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
[Theory]
[InlineData("password", true)]
[InlineData("apiKey", true)]