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; /// /// The socket in the instance directory (#528, stage 2 / E-M1). /// /// The instance manager will reach the instance through it. This test records three /// promises: it is always there, even without a web UI. It needs /// no token — its boundary is the file system. And the TCP port still /// needs one, because there is none there. /// /// The last point is the one where a mix-up would be expensive: whoever wanted to /// guess the distinction from missing IP addresses would, in case of doubt, let a /// TCP request through without a token. It therefore comes from a marker that the /// socket listener sets itself. /// 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 { /* A leftover test folder does not turn a run red. */ } } private AppHost NewHost(bool withWebUi, 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 = withWebUi, WebUiBinding = "loopback", WebUiToken = withWebUi ? Token : "", WebServerPort = port }, InstancePath = instancePath, LogDirectory = Path.Combine(instancePath, "Logs"), LoggerFactory = NullLoggerFactory.Instance, Tools = new ToolRegistry() }; } private static int FreePort() { 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; } // ─── The socket holds ─── [Fact] public async Task The_socket_answers_even_without_web_UI() { // The default: webUiEnabled is off. Formerly nothing ran at all then, and the // instance was not addressable from outside. await using var host = NewHost(withWebUi: false); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); web!.Url.ShouldBeNull(); using var client = InstanceEndpoint.CreateClient(host.InstancePath); var answer = await client.GetAsync("/api/info"); answer.EnsureSuccessStatusCode(); (await answer.Content.ReadAsStringAsync()).ShouldContain("Test"); } [Fact] public async Task Over_the_socket_no_token_is_needed() { await using var host = NewHost(withWebUi: true, port: FreePort()); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); // No Authorization header, on purpose. using var client = InstanceEndpoint.CreateClient(host.InstancePath); (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); } // ─── The port stays guarded ─── [Fact] public async Task Over_TCP_the_token_requirement_still_applies() { var port = FreePort(); await using var host = NewHost(withWebUi: 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(); } // ─── Addresses ─── [Fact] public void Two_instances_get_two_addresses() { 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 The_same_instance_gets_the_same_address() { // The manager computes the address from the path it knows itself — so it // must come out the same from every spelling of the same directory. 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)); } // ─── Cleaning up ─── /// /// A leftover socket file must not prevent the next start. /// /// A Unix domain socket survives the process that created it. After /// a hard end — exactly the case from #529 — the next start would get /// EADDRINUSE from a process that no longer exists. /// [SocketFileFact] public async Task A_leftover_socket_file_does_not_hold_up_the_start() { await using var host = NewHost(withWebUi: false); var corpse = InstanceEndpoint.SocketPath(host.InstancePath); File.WriteAllText(corpse, "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 Shutdown_leaves_no_socket_file_behind() { await using var host = NewHost(withWebUi: false); var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); await web!.DisposeAsync(); File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse(); } } /// /// Control of the instance process: end it and bring its window to the front /// (#528, stages 3 and 5). /// /// Both exist exclusively via the socket. Over TCP the /// endpoints do not exist — not even with a valid token, and there they answer with 404 instead of /// 403: what does not exist need not be explained. The lifetime of an instance /// belongs to the instance manager; a browser session should be able neither to end the process /// serving it nor to tear open its window. /// 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 { /* A leftover test folder does not turn a run red. */ } } private AppHost NewHost(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 FreePort() { 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 Over_TCP_there_is_no_control() { var port = FreePort(); await using var host = NewHost(port); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); web!.Url.ShouldNotBeNull(); var requested = false; web.Control.ShutdownRequested += () => requested = true; using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); // With a valid token - and still not present. (await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode .ShouldBe(System.Net.HttpStatusCode.NotFound); requested.ShouldBeFalse(); } }