Instanzmanager Stufe 1: Protokolle und Sicherungen je Instanz (#528)

Mehrere Instanzen sollen unabhaengig voneinander laufen. Heute tun sie das
nicht: Sie teilen sich Protokollverzeichnis, Sicherungsordner und
Sicherungszeitplan. In einer gemeinsamen Startup.log standen abwechselnd
Zeilen zweier Instanzen, ohne Kennzeichnung, welche gerade spricht -- im
Mehrinstanzbetrieb ist damit jede Fehlersuche Ratearbeit. Genau in dieser
Lage ist der Aufhaenger aus #529 entstanden.

Fuenf Schluessel wandern aus AppSettings nach InstanceSettings.json:
logDirectory, backupDirectory, backupKeepCount, autoBackupEnabled,
autoBackupTime. Leer heisst jetzt <Instanz>/Logs beziehungsweise
<Instanz>/Backups; ein relativer Wert zaehlt weiterhin gegen das
Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses
(#375 bleibt gewahrt).

autoBackupEnabled und autoBackupTime standen in der Planung nicht, sind
aber mitgegangen: Ein Schalter, der die taegliche Sicherung fuer eine
Instanz einschaltet und sie damit fuer alle einschaltet, ist derselbe
Fehler wie ein Ordner fuer alle -- und zwei von vier Sicherungsfeldern zu
verschieben hinterliesse eine Einstellungsseite, auf der die Haelfte
anwendungsweit gilt.

Keine Uebernahme der Altwerte, aber eine Ansage: SettingsManager.
AbandonedKeys meldet die aufgegebenen Schluessel, solange sie noch in
Settings.json stehen, und der Start protokolliert einmal, was nicht mehr
gilt und wohin diese Instanz stattdessen schreibt. Ein anwendungsweiter
Pfad, in alle Instanzen kopiert, waere wieder ein Ordner fuer alle.

webServerPort bleibt vorerst stehen. Es entfaellt mit Stufe 2, sobald
Kestrel auf dem Socket hoert; vorher wegzunehmen hiesse, die
Weboberflaeche ohne Ersatz abzuschalten.

Korrektur an der eigenen Befundlage: "Instanz A wirft die Sicherungen von
Instanz B weg" war zu breit. Beide Rotationen filtern bereits nach dem
Praefix backup_<Instanzname>_. Der reale Defekt ist enger, und ein Praefix
ist keine Grenze: backup_Test_2_*.zip faengt mit backup_Test_ an, also
zaehlt Test im gemeinsamen Ordner die Archive von Test_2 mit. Belegt in
BackupSchedulerTests, das den alten Zustand eigens wiederherstellt.

Nachgeprueft an der laufenden Installation, nicht nur im Test: Nach dem
Umbau schreibt Instance-Test nach Instances/Instance-Test/Logs, das alte
gemeinsame Verzeichnis bekommt keine Zeile mehr, und die Warnung zu den
aufgegebenen Schluesseln erscheint genau einmal.

Enthaelt ausserdem, was seit der Weboberflaeche liegengeblieben war:

- #526: Instanzgeheimnisse wurden beim Speichern verschluesselt und beim
  Laden nie entschluesselt. LoadInstanceConfig hatte kein
  ConfigSecrets.Unprotect; ConfigLoader.LoadAsync macht es richtig, wird
  zur Laufzeit aber gar nicht aufgerufen. Der OpenRouter-Schluessel ging
  so verschluesselt an die API.
- Ein offener Ereignisstrom hielt das Beenden fuenf Sekunden auf. Die
  Stroeme hingen nur an RequestAborted, das beim geordneten Herunterfahren
  nicht feuert; jetzt zusaetzlich an ApplicationStopping. Der Fang in
  WebUiHost gilt dazu ohne Bedingung auf RequestAborted -- mit ihr waere
  jeder offene Strom beim Beenden als Fehler im Bugtracker gelandet.
- Nach der Anmeldung blieb die Weboberflaeche leer: #anmeldung
  { display:flex } schlaegt als ID-Selektor das hidden-Attribut.
- docs/Instanzmanager-Konzept.md: Befund, die Entscheidungen E-M1 bis
  E-M6, der Schnitt zwischen Manager und Instanz und die sechs Stufen.

Build ohne Warnungen, 709 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 21:06:40 +02:00
co-authored by Claude Opus 5
parent 928da7e6af
commit facd21459c
20 changed files with 1404 additions and 158 deletions
@@ -1,80 +1,139 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.App.Tests;
/// <summary>
/// #375: Die Einstellungsseite zeigte ein Log-Verzeichnis, das nie galt.
/// Wohin eine Instanz protokolliert und sichert — und dass zwei Instanzen dabei nicht
/// im selben Ordner landen (#528, Stufe 1).
///
/// <c>InstanceConfig.LogDirectory</c> war mit <c>"./Logs"</c> vorbelegt und damit nie
/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der
/// Wert zudem mit <c>Path.GetFullPath</c> ohne Basis, also gegen das Arbeitsverzeichnis
/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand
/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in <c>/</c>) war das
/// <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.
/// <para><b>Der Anlass.</b> Beide Vorgaben waren anwendungsweit. In <c>Startup.log</c>
/// standen deshalb abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche
/// gerade spricht; und weil die Aufbewahrung nach Präfix in genau einem Ordner
/// aufräumt, warf die laufende Instanz die Archive der anderen mit weg. Solange das so
/// ist, ist jede Fehlersuche im Mehrinstanzbetrieb Ratearbeit.</para>
///
/// <para><b>Was aus #375 bleibt.</b> Ein relativer Wert zählt gegen das
/// Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein
/// systemd-Dienst ohne <c>WorkingDirectory=</c> startet in <c>/</c>, und <c>"./Logs"</c>
/// zeigte dann auf <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.</para>
/// </summary>
public sealed class LogDirectoryResolutionTests
{
private static readonly string InstancePath =
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test"));
private static readonly string ApplicationLogDirectory =
Path.Combine(AppPaths.DataDirectory, "Logs");
private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null)
private static InstanceConfig Instance(
string logDirectory = "", string backupDirectory = "", string? workingDirectory = null)
=> new()
{
LogDirectory = logDirectory,
BackupDirectory = backupDirectory,
WorkingDirectory = workingDirectory ?? InstancePath
};
// ─── Vorgaben ───
[Fact]
public void Die_Vorbelegung_ist_leer()
public void Die_Vorbelegungen_sind_leer()
{
// Der Kern des Befunds: Solange hier "./Logs" stand, konnte die
// Anwendungseinstellung nicht greifen.
// Der Kern: Leer heisst „nimm den Ordner dieser Instanz". Steht hier ein Wert,
// traegt ihn jede neue Instanz mit sich herum.
new InstanceConfig().LogDirectory.ShouldBe("");
new InstanceConfig().BackupDirectory.ShouldBe("");
}
[Fact]
public void Ohne_Angabe_in_der_Instanz_gilt_die_Anwendungseinstellung()
public void Ohne_Angabe_protokolliert_die_Instanz_in_ihr_eigenes_Verzeichnis()
{
AppHost.ResolveLogDirectory(Instance(""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
AppHost.ResolveLogDirectory(Instance(), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_absoluter_Wert_in_der_Instanz_geht_vor()
public void Ohne_Angabe_sichert_die_Instanz_in_ihr_eigenes_Verzeichnis()
{
var eigen = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
AppHost.ResolveBackupDirectory(Instance(), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Backups"));
}
AppHost.ResolveLogDirectory(Instance(eigen), ApplicationLogDirectory, InstancePath)
.ShouldBe(eigen);
/// <summary>
/// Das eigentliche Versprechen von Stufe 1. Alles andere in dieser Datei prüft die
/// Mechanik; dieser Test prüft die Zusage.
/// </summary>
[Fact]
public void Zwei_Instanzen_ohne_Angabe_teilen_sich_nichts()
{
var a = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-A"));
var b = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-B"));
var logA = AppHost.ResolveLogDirectory(Instance(workingDirectory: a), a);
var logB = AppHost.ResolveLogDirectory(Instance(workingDirectory: b), b);
var backupA = AppHost.ResolveBackupDirectory(Instance(workingDirectory: a), a);
var backupB = AppHost.ResolveBackupDirectory(Instance(workingDirectory: b), b);
logA.ShouldNotBe(logB);
backupA.ShouldNotBe(backupB);
}
// ─── Eingetragene Werte ───
[Fact]
public void Ein_absoluter_Wert_geht_vor()
{
var eigenesLog = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
var eigenesBackup = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-sicherungen"));
AppHost.ResolveLogDirectory(Instance(logDirectory: eigenesLog), InstancePath)
.ShouldBe(eigenesLog);
AppHost.ResolveBackupDirectory(Instance(backupDirectory: eigenesBackup), InstancePath)
.ShouldBe(eigenesBackup);
}
[Fact]
public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis()
{
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler.
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler aus #375.
AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Protokolle"));
}
[Fact]
public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses()
{
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldNotBe(Path.GetFullPath("./Logs"));
AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath)
.ShouldNotBe(Path.GetFullPath("./Protokolle"));
}
[Theory]
[InlineData("./Logs")]
[InlineData("Logs")]
public void Die_alte_Vorbelegung_zeigt_auf_dieselbe_Stelle_wie_keine_Angabe(string alt)
{
// Deshalb darf LoadInstanceConfig sie zu „leer" machen, ohne dass sich etwas
// verschiebt.
AppHost.ResolveLogDirectory(Instance(logDirectory: alt), InstancePath)
.ShouldBe(AppHost.ResolveLogDirectory(Instance(), InstancePath));
}
[Fact]
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
{
AppHost.ResolveLogDirectory(Instance(logDirectory: " "), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
// ─── Bezugspunkt ───
[Fact]
public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck()
{
// Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand
// geleert worden sein. Dann trägt der Ort, aus dem geladen wurde.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath)
Instance(logDirectory: "Logs", workingDirectory: ""), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
@@ -84,14 +143,7 @@ public sealed class LogDirectoryResolutionTests
// "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das
// Arbeitsverzeichnis des Prozesses aufgelöst worden.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath)
Instance(logDirectory: "Logs", workingDirectory: "./data/"), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
{
AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
}
@@ -0,0 +1,201 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die tägliche Sicherung, seit sie der Instanz gehört (#528, Stufe 1).
///
/// <para>Schalter, Uhrzeit, Zielordner und Aufbewahrung standen in den
/// Anwendungseinstellungen und galten damit für jede Instanz. Zwei Folgen: Wer die
/// Sicherung für eine Instanz einschaltete, schaltete sie für alle ein — und alle
/// Archive lagen in einem Ordner, in dem die Aufbewahrung nach dem Präfix
/// <c>backup_&lt;Instanzname&gt;_</c> aufräumt. Dass ein Präfix keine Grenze ist, zeigt
/// <see cref="Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus"/>.</para>
/// </summary>
public sealed class BackupSchedulerTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
/// <summary>Ein Instanzverzeichnis mit genug Inhalt, dass sich eine Sicherung lohnt.</summary>
private string NeueInstanz(string name)
{
var dir = Path.Combine(_root, "Instance-" + name);
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "InstanceSettings.json"), $$"""{"instanceName":"{{name}}"}""");
return dir;
}
private static InstanceConfig Config(string name, string? backupDirectory = null, int keep = 14)
=> new()
{
InstanceName = name,
WorkingDirectory = "",
AutoBackupEnabled = true,
AutoBackupTime = "03:00",
BackupDirectory = backupDirectory ?? "",
BackupKeepCount = keep
};
/// <summary>
/// Eine Uhr, die auf einer Zeit steht — der Zeitpunkt entscheidet, ob
/// <c>TickAsync</c> etwas tut.
/// </summary>
private sealed class FesteUhr(DateTime jetzt) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(jetzt, TimeSpan.Zero);
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private static BackupScheduler Scheduler(string dir, InstanceConfig config, DateTime jetzt)
=> new(dir, config, NullLogger.Instance, new FesteUhr(jetzt));
private static DateTime Nachts(int tag) => new(2026, 9, tag, 4, 0, 0, DateTimeKind.Utc);
// ─── Der Zielordner ───
[Fact]
public async Task Ohne_Angabe_landet_die_Sicherung_im_Instanzverzeichnis()
{
var dir = NeueInstanz("Allein");
await Scheduler(dir, Config("Allein"), Nachts(1)).TickAsync();
var ziel = Path.Combine(dir, "Backups");
Directory.Exists(ziel).ShouldBeTrue();
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(1);
}
[Fact]
public async Task Zwei_Instanzen_sichern_ohne_Angabe_getrennt()
{
var a = NeueInstanz("Eins");
var b = NeueInstanz("Zwei");
await Scheduler(a, Config("Eins"), Nachts(1)).TickAsync();
await Scheduler(b, Config("Zwei"), Nachts(1)).TickAsync();
Directory.GetFiles(Path.Combine(a, "Backups"), "*.zip").Length.ShouldBe(1);
Directory.GetFiles(Path.Combine(b, "Backups"), "*.zip").Length.ShouldBe(1);
}
// ─── Der Schalter ───
[Fact]
public async Task Ausgeschaltet_passiert_nichts()
{
var dir = NeueInstanz("Ruhig");
var config = Config("Ruhig");
config.AutoBackupEnabled = false;
await Scheduler(dir, config, Nachts(1)).TickAsync();
Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse();
}
[Fact]
public async Task Vor_der_eingestellten_Uhrzeit_passiert_nichts()
{
var dir = NeueInstanz("Frueh");
await Scheduler(dir, Config("Frueh"), new DateTime(2026, 9, 1, 2, 0, 0, DateTimeKind.Utc))
.TickAsync();
Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse();
}
[Fact]
public async Task Zweimal_am_selben_Tag_sichert_nur_einmal()
{
var dir = NeueInstanz("Einmal");
var scheduler = Scheduler(dir, Config("Einmal"), Nachts(1));
await scheduler.TickAsync();
await scheduler.TickAsync();
Directory.GetFiles(Path.Combine(dir, "Backups"), "*.zip").Length.ShouldBe(1);
}
// ─── Die Aufbewahrung ───
[Fact]
public async Task Die_Aufbewahrung_raeumt_die_eigenen_Archive_auf()
{
var dir = NeueInstanz("Alt");
var ziel = Path.Combine(dir, "Backups");
Directory.CreateDirectory(ziel);
// Drei Archive aus der Vergangenheit, die aelter sind als das gleich entstehende.
foreach (var tag in new[] { 1, 2, 3 })
{
var datei = Path.Combine(ziel, $"backup_Alt_2026-08-0{tag}_0300.zip");
File.WriteAllText(datei, "alt");
File.SetLastWriteTime(datei, new DateTime(2026, 8, tag, 3, 0, 0));
}
await Scheduler(dir, Config("Alt", keep: 2), Nachts(1)).TickAsync();
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2);
}
[Fact]
public async Task Aufbewahrung_null_behaelt_alles()
{
var dir = NeueInstanz("Sammler");
var ziel = Path.Combine(dir, "Backups");
Directory.CreateDirectory(ziel);
File.WriteAllText(Path.Combine(ziel, "backup_Sammler_2026-08-01_0300.zip"), "alt");
await Scheduler(dir, Config("Sammler", keep: 0), Nachts(1)).TickAsync();
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2);
}
/// <summary>
/// Warum der Zielordner der Instanz gehören muss.
///
/// <para>Die Aufbewahrung sucht nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c>,
/// und ein Präfix ist keine Grenze: <c>backup_Test_2_….zip</c> — das Archiv der
/// Instanz <c>Test_2</c> — fängt mit <c>backup_Test_</c> an. Im gemeinsamen Ordner
/// zählt <c>Test</c> es also zu den eigenen und wirft es weg. Getrennte Ordner
/// nehmen der Namensfrage ihre Wirkung: Der Test hier stellt den alten Zustand
/// eigens wieder her, indem er beiden Instanzen denselben Ordner einträgt.</para>
/// </summary>
[Fact]
public async Task Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus()
{
var gemeinsam = Path.Combine(_root, "Backups");
Directory.CreateDirectory(gemeinsam);
var fremd = Path.Combine(gemeinsam, "backup_Test_2_2026-08-01_0300.zip");
File.WriteAllText(fremd, "gehoert Test_2");
File.SetLastWriteTime(fremd, new DateTime(2026, 8, 1, 3, 0, 0));
var dir = NeueInstanz("Test");
await Scheduler(dir, Config("Test", gemeinsam, keep: 1), Nachts(1)).TickAsync();
File.Exists(fremd).ShouldBeFalse(
"Der alte Zustand ist damit belegt — im gemeinsamen Ordner räumt eine "
+ "Instanz über ihre eigenen Archive hinaus auf.");
// Und die Gegenprobe: mit getrennten Ordnern kommen sie sich nicht in die Quere.
var eigen = NeueInstanz("Test2");
var eigenesArchiv = Path.Combine(eigen, "Backups", "backup_Test2_2026-08-01_0300.zip");
Directory.CreateDirectory(Path.GetDirectoryName(eigenesArchiv)!);
File.WriteAllText(eigenesArchiv, "gehoert Test2");
var dir2 = NeueInstanz("Test3");
await Scheduler(dir2, Config("Test3", keep: 1), Nachts(2)).TickAsync();
File.Exists(eigenesArchiv).ShouldBeTrue();
}
}
@@ -8,8 +8,11 @@ namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die andere Hälfte von #375: Bestehende Instanzen tragen <c>"logDirectory": "./Logs"</c>
/// in ihrer <c>InstanceSettings.json</c> — <see cref="InstanceDirectoryManager.CreateInstance"/>
/// hat es dort hineingeschrieben. Der Wert hatte nie die Bedeutung, die er vorgab, und
/// wird beim Laden zu „leer", also zur Anwendungseinstellung.
/// hat es dort hineingeschrieben. Beim Laden wird daraus „leer".
///
/// <para>Seit #528 (Stufe 1) zeigen beide Schreibweisen ohnehin auf denselben Ort:
/// <c>&lt;Instanz&gt;/Logs</c>. Der leere Wert sagt das, ohne es festzuschreiben — zieht
/// die Instanz um, zieht das Protokoll mit.</para>
///
/// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn.
/// </summary>
@@ -81,8 +84,31 @@ public sealed class InstanceLogDirectoryMigrationTests : IDisposable
public void Eine_neue_Instanz_bekommt_gar_keinen_Wert()
{
var dir = _manager.CreateInstance("Frisch");
var config = _manager.LoadInstanceConfig(dir);
config.LogDirectory.ShouldBe("");
config.BackupDirectory.ShouldBe("");
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
File.ReadAllText(Path.Combine(dir, "InstanceSettings.json")).ShouldNotContain("./Logs");
}
/// <summary>
/// Der eigentliche Zweck von Stufe 1: Zwei frisch angelegte Instanzen protokollieren
/// und sichern nicht in denselben Ordner.
/// </summary>
[Fact]
public void Zwei_frische_Instanzen_landen_nicht_im_selben_Ordner()
{
var a = _manager.CreateInstance("Eins");
var b = _manager.CreateInstance("Zwei");
var configA = _manager.LoadInstanceConfig(a);
var configB = _manager.LoadInstanceConfig(b);
AppHost.ResolveLogDirectory(configA, a)
.ShouldNotBe(AppHost.ResolveLogDirectory(configB, b));
AppHost.ResolveBackupDirectory(configA, a)
.ShouldNotBe(AppHost.ResolveBackupDirectory(configB, b));
}
}
@@ -0,0 +1,92 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Zugangsdaten der Instanz müssen den Weg über die Platte und zurück überstehen.
///
/// <para><c>SaveInstanceConfig</c> verschlüsselte beim Schreiben, <c>LoadInstanceConfig</c>
/// entschlüsselte beim Lesen aber nicht. Was einmal über die Oberfläche gespeichert war,
/// kam als <c>enc:v2:…</c> zurück und wurde als Wert benutzt — der OpenRouter-Schlüssel
/// ging so verschlüsselt an die API.</para>
///
/// <para>Unentdeckt geblieben ist es, weil <c>ConfigLoader.LoadAsync</c> es richtig macht
/// und im laufenden Programm gar nicht aufgerufen wird: Gebraucht wird von dieser Klasse
/// nur <c>Migrate</c>.</para>
/// </summary>
public sealed class InstanceSecretRoundtripTests
{
[Fact]
public void Zugangsdaten_kommen_im_Klartext_zurueck()
{
using var host = new TestHost();
host.Host.Instance.OpenRouterApiKey = "sk-or-v1-beispielschluessel";
host.Host.Instance.WebUiToken = "ein-hinreichend-langes-token";
host.Host.Instance.Watchdog.AgentToken = "dc-token-beispiel";
host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
var geladen = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
geladen.OpenRouterApiKey.ShouldBe("sk-or-v1-beispielschluessel");
geladen.WebUiToken.ShouldBe("ein-hinreichend-langes-token");
geladen.Watchdog.AgentToken.ShouldBe("dc-token-beispiel");
}
[Fact]
public void In_der_Datei_stehen_sie_trotzdem_geschuetzt()
{
// Die Gegenprobe: Entschluesseln beim Laden darf nicht heissen, dass gar nicht
// mehr verschluesselt wird.
using var host = new TestHost();
host.Host.Instance.OpenRouterApiKey = "sk-or-v1-beispielschluessel";
host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
var rohtext = File.ReadAllText(
Path.Combine(host.Host.InstancePath, "InstanceSettings.json"));
rohtext.ShouldNotContain("sk-or-v1-beispielschluessel");
}
[Fact]
public void Klartext_aus_aelteren_Dateien_bleibt_lesbar()
{
// Entschluesseln muss gefahrlos auf etwas anwendbar sein, das nie verschluesselt
// war — sonst braeche der erste Start nach dem Update jede bestehende Instanz.
using var host = new TestHost();
var pfad = Path.Combine(host.Host.InstancePath, "InstanceSettings.json");
File.WriteAllText(pfad, """
{
"instanceId": "alt",
"instanceName": "Alt",
"openRouterApiKey": "klartext-von-hand-eingetragen",
"webUiToken": "auch-klartext"
}
""");
var geladen = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
geladen.OpenRouterApiKey.ShouldBe("klartext-von-hand-eingetragen");
geladen.WebUiToken.ShouldBe("auch-klartext");
}
[Fact]
public void Zweimal_speichern_verschluesselt_nicht_zweimal()
{
using var host = new TestHost();
host.Host.Instance.WebUiToken = "ein-hinreichend-langes-token";
host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath)
.WebUiToken.ShouldBe("ein-hinreichend-langes-token");
}
}
@@ -0,0 +1,106 @@
using ClawdDotNet.App.Settings;
using Shouldly;
namespace ClawdDotNet.App.Tests.Settings;
/// <summary>
/// Was mit den Einstellungen geschieht, die es hier nicht mehr gibt (#528, Stufe 1).
///
/// <para><b>Der Anlass.</b> Fünf Schlüssel sind aus <see cref="AppSettings"/> in die
/// Instanz gewandert. System.Text.Json überliest unbekannte Schlüssel wortlos, und
/// <c>Save</c> schreibt sie danach nicht wieder — wer ein eigenes Log- oder
/// Sicherungsverzeichnis eingetragen hatte, verlöre den Wert also, ohne es zu erfahren.
/// Ein Umzug ohne Ausweichlogik ist in Ordnung; einer ohne Ansage nicht.</para>
///
/// <para>Bewusst keine Übernahme in die Instanzen: Ein anwendungsweiter Pfad, in alle
/// Instanzen kopiert, wäre wieder derselbe Ordner für alle — also genau der Zustand, den
/// Stufe 1 beseitigt.</para>
/// </summary>
public sealed class SettingsManagerTests : IDisposable
{
private readonly string _dir = Path.Combine(
Path.GetTempPath(), "clawd-tests", "settings-" + Guid.NewGuid().ToString("N")[..8]);
private string Datei => Path.Combine(_dir, "Settings.json");
public SettingsManagerTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
private SettingsManager Geladen(string json)
{
File.WriteAllText(Datei, json);
var manager = new SettingsManager(_dir);
manager.Load();
return manager;
}
[Fact]
public void Eine_alte_Datei_meldet_ihre_aufgegebenen_Schluessel()
{
var manager = Geladen("""
{
"logDirectory": "/var/log/clawd",
"backupDirectory": "/srv/sicherungen",
"backupKeepCount": 30,
"autoBackupEnabled": true,
"autoBackupTime": "02:30",
"minimumLogLevel": "Debug"
}
""");
manager.AbandonedKeys.ShouldBe(
["logDirectory", "backupDirectory", "backupKeepCount",
"autoBackupEnabled", "autoBackupTime"],
ignoreOrder: true);
}
[Fact]
public void Was_die_Datei_sonst_enthaelt_wird_weiter_gelesen()
{
// Die Ansage darf nichts daran ändern, dass der Rest ankommt.
var manager = Geladen("""
{
"logDirectory": "/var/log/clawd",
"minimumLogLevel": "Debug",
"maxLogLinesInUi": 4000
}
""");
manager.AppSettings.MinimumLogLevel.ShouldBe("Debug");
manager.AppSettings.MaxLogLinesInUi.ShouldBe(4000);
}
[Fact]
public void Eine_frische_Datei_meldet_nichts()
{
Geladen("""{ "minimumLogLevel": "Info" }""").AbandonedKeys.ShouldBeEmpty();
}
[Fact]
public void Speichern_wird_die_aufgegebenen_Schluessel_los()
{
var manager = Geladen("""{ "logDirectory": "/var/log/clawd" }""");
manager.Save();
File.ReadAllText(Datei).ShouldNotContain("logDirectory");
// Und der zweite Ladevorgang hat nichts mehr zu melden.
var erneut = new SettingsManager(_dir);
erneut.Load();
erneut.AbandonedKeys.ShouldBeEmpty();
}
[Fact]
public void Eine_unlesbare_Datei_verhindert_den_Start_nicht()
{
var manager = Geladen("{ das ist kein JSON");
manager.AppSettings.MinimumLogLevel.ShouldBe("Info");
manager.AbandonedKeys.ShouldBeEmpty();
}
}
@@ -0,0 +1,146 @@
using System.Diagnostics;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Web.Tests;
/// <summary>
/// Die Weboberfläche muss sich zügig beenden lassen — auch mit offenen Ereignisströmen.
///
/// <para><b>Der Anlass.</b> Ein Ereignisstrom endet nie von selbst. Beim geordneten
/// Herunterfahren legt der Browser nicht auf, also feuert <c>RequestAborted</c> nicht,
/// und Kestrel wartet geduldig darauf, dass die Anfrage fertig wird. Erst nach der Frist
/// von <c>StopAsync</c> wurde abgeschnitten: fünf Sekunden, in denen die Anwendung stand.
/// Sichtbar wurde es beim Instanzwechsel — zwischen „Wechseln" und dem Neustart lag jedes
/// Mal diese Pause, sobald irgendwo ein Browserfenster offen war.</para>
///
/// <para>Die Ströme hängen jetzt zusätzlich an <c>ApplicationStopping</c>. Dieser Test
/// hält die Zusage fest, damit sie nicht beim nächsten Umbau still verlorengeht.</para>
/// </summary>
public sealed class ShutdownTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "web-" + Guid.NewGuid().ToString("N")[..8]);
private const string Token = "ein-hinreichend-langes-testtoken";
/// <summary>
/// Grosszuegig bemessen: Gemessen wird gegen die Frist von StopAsync (fuenf
/// Sekunden). Bleibt der Test unter zwei, ist die Zusage eingehalten, ohne dass ein
/// langsamer Bauserver ihn rot faerbt.
/// </summary>
private static readonly TimeSpan Grenze = TimeSpan.FromSeconds(2);
private AppHost NeuerHost(int port)
{
var instancesDir = Path.Combine(_root, "Instances");
var instancePath = Path.Combine(instancesDir, "Test");
Directory.CreateDirectory(instancePath);
return new AppHost
{
Settings = new SettingsManager(_root),
Directories = new InstanceDirectoryManager(instancesDir),
Instance = new InstanceConfig
{
InstanceName = "Test",
WebUiEnabled = true,
WebUiBinding = "loopback",
WebUiToken = Token,
WebServerPort = port
},
InstancePath = instancePath,
LogDirectory = Path.Combine(_root, "Logs"),
LoggerFactory = NullLoggerFactory.Instance,
Tools = new ToolRegistry()
};
}
/// <summary>
/// Ein freier Port vom Betriebssystem. Eine feste Zahl liesse zwei Testlaeufe
/// nebeneinander scheitern — und genau solche Fehler sucht man am laengsten.
/// </summary>
private static int FreierPort()
{
using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
probe.Start();
var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
[Fact]
public async Task Beenden_wartet_nicht_auf_einen_offenen_Ereignisstrom()
{
var port = FreierPort();
await using var host = NeuerHost(port);
var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
// Den Strom wirklich oeffnen und die erste Zeile abwarten: Erst dann haelt
// Kestrel die Anfrage, und erst dann prueft der Test das, was er pruefen will.
using var antwort = await client.GetAsync(
$"http://127.0.0.1:{port}/events?token={Token}",
HttpCompletionOption.ResponseHeadersRead);
antwort.EnsureSuccessStatusCode();
await using var strom = await antwort.Content.ReadAsStreamAsync();
using var leser = new StreamReader(strom);
(await leser.ReadLineAsync()).ShouldBe(": bereit");
var uhr = Stopwatch.StartNew();
await web!.DisposeAsync();
uhr.Stop();
uhr.Elapsed.ShouldBeLessThan(Grenze,
$"Das Beenden dauerte {uhr.Elapsed.TotalSeconds:0.0}s — der Ereignisstrom "
+ "hat das Herunterfahren nicht bemerkt.");
}
[Fact]
public async Task Beenden_geht_auch_ohne_offene_Verbindung_zuegig()
{
var port = FreierPort();
await using var host = NeuerHost(port);
var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
var uhr = Stopwatch.StartNew();
await web!.DisposeAsync();
uhr.Stop();
uhr.Elapsed.ShouldBeLessThan(Grenze);
}
[Fact]
public void Ein_belegter_Port_haelt_die_Anwendung_nicht_auf()
{
var port = FreierPort();
using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port);
blockierer.Start();
var host = NeuerHost(port);
// Kein Wurf, sondern null: Ein Fenster, das nicht aufgeht, weil ein Webserver
// keine Adresse bekommt, waere genau dann im Weg, wenn man es braucht.
WebUiHost.TryStart(host, NullLoggerFactory.Instance).ShouldBeNull();
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
}