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();
}
}