Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
9.7 KiB
C#
274 lines
9.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The socket in the instance directory (#528, stage 2 / E-M1).
|
|
///
|
|
/// <para>The instance manager will reach the instance through it. This test records three
|
|
/// promises: it is <b>always</b> there, even without a web UI. It needs
|
|
/// <b>no token</b> — its boundary is the file system. And the TCP port still
|
|
/// needs one, because there is none there.</para>
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
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 ───
|
|
|
|
/// <summary>
|
|
/// A leftover socket file must not prevent the next start.
|
|
///
|
|
/// <para>A Unix domain socket survives the process that created it. After
|
|
/// a hard end — exactly the case from #529 — the next start would get
|
|
/// <c>EADDRINUSE</c> from a process that no longer exists.</para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Control of the instance process: end it and bring its window to the front
|
|
/// (#528, stages 3 and 5).
|
|
///
|
|
/// <para><b>Both exist exclusively via the socket.</b> 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.</para>
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|