using System.Net; using System.Net.Http.Json; using ClawdDotNet.Core.Instances; using ClawdDotNet.Manager.Web; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// /// #764: The shell's one-time sign-in. A new way in — so the three ways it must fail are tested: /// used twice, used too late, asked for over TCP instead of the local socket. /// public sealed class ShellLoginTests { private sealed class Clock(DateTimeOffset start) : TimeProvider { public DateTimeOffset Now { get; set; } = start; public override DateTimeOffset GetUtcNow() => Now; } [Fact] public void A_code_works_once() { var login = new ShellLogin(); var code = login.Issue(); login.Redeem(code).ShouldBeTrue(); login.Redeem(code).ShouldBeFalse(); } [Fact] public void A_code_expires_after_a_minute() { var clock = new Clock(DateTimeOffset.UnixEpoch); var login = new ShellLogin(clock); var code = login.Issue(); clock.Now += ShellLogin.Lifetime + TimeSpan.FromSeconds(1); login.Redeem(code).ShouldBeFalse(); } [Fact] public void A_made_up_code_fails() { var login = new ShellLogin(); login.Issue(); login.Redeem("guess").ShouldBeFalse(); login.Redeem("").ShouldBeFalse(); login.Redeem(null).ShouldBeFalse(); } [Fact] public void Codes_are_not_predictable() { var login = new ShellLogin(); login.Issue().ShouldNotBe(login.Issue()); login.Issue().Length.ShouldBeGreaterThanOrEqualTo(40); } } /// #764: The same promises against the running web host, over real TCP and the real socket. public sealed class ShellLoginHostTests : IAsyncDisposable { private const string Token = "a-sufficiently-long-test-token-for-the-webui"; private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "shl-" + Guid.NewGuid().ToString("N")[..8]); public async ValueTask DisposeAsync() { await Task.CompletedTask; try { Directory.Delete(_root, recursive: true); } catch { /* A leftover test folder does not turn a run red. */ } } private static int FreePort() { using var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); var port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return port; } private async Task<(ManagerWebHost Web, ManagerCore Manager, HttpClient Tcp, HttpClient Socket)> StartAsync() { Directory.CreateDirectory(_root); var store = new ManagerSettingsStore(_root); store.Settings.WebUiToken = Token; store.Settings.WebUiPort = FreePort(); store.Settings.InstancesDirectory = Path.Combine(_root, "Instances"); var registry = new InstanceRegistry( new InstanceDirectoryManager(store.Settings.InstancesDirectory), new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance, SupervisionOptions.Default); var manager = new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance); await manager.StartAsync(); var backups = new ManagerBackups(store, registry, null, NullLogger.Instance); var web = ManagerWebHost.TryStart(manager, backups, NullLoggerFactory.Instance); web.ShouldNotBeNull(); var tcp = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{store.Settings.WebUiPort}") }; var socket = ManagerEndpoint.CreateClient(_root); return (web!, manager, tcp, socket); } [Fact] public async Task The_code_comes_only_over_the_socket() { var (web, manager, tcp, socket) = await StartAsync(); await using var _ = web; await using var __ = manager; // Over TCP: not there, even with the access token. tcp.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); (await tcp.PostAsync("/api/shell/login-code", null)).StatusCode.ShouldBe(HttpStatusCode.NotFound); // Over the socket: there, without a token. var answer = await socket.PostAsync("/api/shell/login-code", null); answer.StatusCode.ShouldBe(HttpStatusCode.OK); } [Fact] public async Task The_socket_leads_nowhere_but_the_shell_endpoints() { var (web, manager, _, socket) = await StartAsync(); await using var a = web; await using var b = manager; // No token needed on the socket - so nothing else may be reachable there. (await socket.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.NotFound); (await socket.GetAsync("/api/verwaltung/license")).StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact] public async Task A_code_signs_in_once() { var (web, manager, tcp, socket) = await StartAsync(); await using var a = web; await using var b = manager; var issued = await (await socket.PostAsync("/api/shell/login-code", null)).Content.ReadFromJsonAsync(); issued!.Url.ShouldContain("#code="); // in the fragment, never the query var first = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code)); first.StatusCode.ShouldBe(HttpStatusCode.OK); (await first.Content.ReadFromJsonAsync())!.Token.ShouldBe(Token); var second = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code)); second.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] public async Task Without_a_code_or_token_nothing_is_open() { var (web, manager, tcp, _) = await StartAsync(); await using var a = web; await using var b = manager; (await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto("made-up"))).StatusCode .ShouldBe(HttpStatusCode.Unauthorized); (await tcp.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } }