using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Tools; using ClawdDotNet.Web.Endpoints; using ClawdDotNet.Web.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Web.Tests; /// /// Der Socket im Instanzverzeichnis (#528, Stufe 2 / E-M1). /// /// Über ihn wird der Instanzmanager die Instanz erreichen. Drei Zusagen hält /// dieser Test fest: Er ist immer da, auch ohne Weboberfläche. Er braucht /// kein Token — seine Grenze steht am Dateisystem. Und der TCP-Port braucht /// weiterhin eins, denn dort steht keine. /// /// Der letzte Punkt ist der, an dem eine Verwechslung teuer wäre: Wer die /// Unterscheidung aus fehlenden IP-Adressen erraten wollte, ließe im Zweifel eine /// TCP-Anfrage ohne Token durch. Sie kommt deshalb aus einer Marke, die der /// Socket-Zuhörer selbst setzt. /// public sealed class InstanceSocketTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "sock-" + Guid.NewGuid().ToString("N")[..8]); private const string Token = "ein-hinreichend-langes-testtoken"; public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } private AppHost NeuerHost(bool mitWeboberflaeche, int port = 0) { 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", WorkingDirectory = instancePath, WebUiEnabled = mitWeboberflaeche, WebUiBinding = "loopback", WebUiToken = mitWeboberflaeche ? Token : "", WebServerPort = port }, InstancePath = instancePath, LogDirectory = Path.Combine(instancePath, "Logs"), LoggerFactory = NullLoggerFactory.Instance, Tools = new ToolRegistry() }; } 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; } // ─── Der Socket trägt ─── [Fact] public async Task Der_Socket_antwortet_auch_ohne_Weboberflaeche() { // Die Vorbelegung: webUiEnabled ist aus. Frueher lief dann gar nichts, und die // Instanz war von aussen nicht ansprechbar. await using var host = NeuerHost(mitWeboberflaeche: false); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); web!.Url.ShouldBeNull(); using var client = InstanceEndpoint.CreateClient(host.InstancePath); var antwort = await client.GetAsync("/api/info"); antwort.EnsureSuccessStatusCode(); (await antwort.Content.ReadAsStringAsync()).ShouldContain("Test"); } [Fact] public async Task Ueber_den_Socket_braucht_es_kein_Token() { await using var host = NeuerHost(mitWeboberflaeche: true, port: FreierPort()); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); // Kein Authorization-Kopf, mit Absicht. using var client = InstanceEndpoint.CreateClient(host.InstancePath); (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); } // ─── Der Port bleibt bewacht ─── [Fact] public async Task Ueber_TCP_gilt_die_Token_Pflicht_weiter() { var port = FreierPort(); await using var host = NeuerHost(mitWeboberflaeche: true, port: port); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); web!.Url.ShouldNotBeNull(); using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; (await client.GetAsync("/api/info")).StatusCode .ShouldBe(System.Net.HttpStatusCode.Unauthorized); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); } // ─── Adressen ─── [Fact] public void Zwei_Instanzen_bekommen_zwei_Adressen() { var a = Path.Combine(_root, "Instances", "Eins"); var b = Path.Combine(_root, "Instances", "Zwei"); InstanceEndpoint.Describe(a).ShouldNotBe(InstanceEndpoint.Describe(b)); InstanceEndpoint.PipeName(a).ShouldNotBe(InstanceEndpoint.PipeName(b)); } [Fact] public void Dieselbe_Instanz_bekommt_dieselbe_Adresse() { // Der Manager rechnet die Adresse aus dem Pfad aus, den er selbst kennt — sie // muss also aus jeder Schreibweise desselben Verzeichnisses gleich herauskommen. var a = Path.Combine(_root, "Instances", "Test"); var b = Path.Combine(_root, "Instances", "Unterwegs", "..", "Test"); InstanceEndpoint.PipeName(a).ShouldBe(InstanceEndpoint.PipeName(b)); InstanceEndpoint.SocketPath(a).ShouldBe(InstanceEndpoint.SocketPath(b)); } // ─── Aufräumen ─── /// /// Eine liegengebliebene Socketdatei darf den nächsten Start nicht verhindern. /// /// Ein Unix-Domain-Socket überlebt den Prozess, der ihn angelegt hat. Nach /// einem harten Ende — genau dem Fall aus #529 — bekäme der nächste Start /// EADDRINUSE von einem Prozess, den es nicht mehr gibt. /// [SocketFileFact] public async Task Eine_liegengebliebene_Socketdatei_haelt_den_Start_nicht_auf() { await using var host = NeuerHost(mitWeboberflaeche: false); var leiche = InstanceEndpoint.SocketPath(host.InstancePath); File.WriteAllText(leiche, "Rest eines abgestuerzten Prozesses"); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); using var client = InstanceEndpoint.CreateClient(host.InstancePath); (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); } [SocketFileFact] public async Task Beim_Beenden_bleibt_keine_Socketdatei_liegen() { await using var host = NeuerHost(mitWeboberflaeche: false); var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); await web!.DisposeAsync(); File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse(); } } /// /// Die Steuerung des Instanzprozesses: beenden und Fenster nach vorn holen /// (#528, Stufen 3 und 5). /// /// Beides gibt es ausschließlich über den Socket. Über TCP existieren die /// Endpunkte nicht — auch mit gültigem Token nicht, und sie antworten dort mit 404 statt /// mit 403: Was es nicht gibt, muss man auch nicht erklären. Die Lebensdauer einer Instanz /// gehört dem Instanzmanager; eine Browsersitzung soll den Prozess, der sie bedient, weder /// beenden noch sein Fenster aufreißen können. /// public sealed class InstanceControlTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "ctl-" + Guid.NewGuid().ToString("N")[..8]); private const string Token = "ein-hinreichend-langes-testtoken"; public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } 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", WorkingDirectory = instancePath, WebUiEnabled = true, WebUiBinding = "loopback", WebUiToken = Token, WebServerPort = port }, InstancePath = instancePath, LogDirectory = Path.Combine(instancePath, "Logs"), LoggerFactory = NullLoggerFactory.Instance, Tools = new ToolRegistry() }; } 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 Ueber_den_Socket_kommt_die_Bitte_um_das_Fenster_an() { await using var host = NeuerHost(FreierPort()); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); var gebeten = 0; web!.Control.FocusRequested += () => Interlocked.Increment(ref gebeten); using var client = InstanceEndpoint.CreateClient(host.InstancePath); (await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode(); gebeten.ShouldBe(1); // Und beliebig oft: Jeder Wechsel auf diese Instanz ist einer. (await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode(); gebeten.ShouldBe(2); } [Fact] public async Task Ueber_TCP_gibt_es_die_Steuerung_nicht() { var port = FreierPort(); await using var host = NeuerHost(port); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); web!.Url.ShouldNotBeNull(); var gebeten = false; web.Control.FocusRequested += () => gebeten = true; using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); // Mit gueltigem Token - und trotzdem nicht vorhanden. (await client.PostAsync("/api/instanz/fenster", content: null)).StatusCode .ShouldBe(System.Net.HttpStatusCode.NotFound); (await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode .ShouldBe(System.Net.HttpStatusCode.NotFound); gebeten.ShouldBeFalse(); } }