using System.Globalization; using System.Net; using System.Net.WebSockets; using System.Text; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Terminal.Tests; /// /// The loopback transport end to end, with a real WebSocket client. /// /// /// Uses against the real listener rather than a stubbed transport, /// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that /// no real client accepts would pass any test that skipped it. /// public sealed class TerminalDataPlaneTests : IAsyncDisposable { private const uint SessionId = 3; private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes( $""); private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider( new Dictionary(StringComparer.Ordinal) { [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate), ["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()), })); private readonly CancellationTokenSource lifetime = new(); private Task? server; /// public async ValueTask DisposeAsync() { await lifetime.CancelAsync(); await plane.DisposeAsync(); if (server is not null) { try { await server; } catch (OperationCanceledException) { // Expected: the accept loop is stopped by cancelling it. } } lifetime.Dispose(); } // ---- Serving the page ---- [Fact] public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted() { // Substituted at serve time rather than written into the file, so the token never touches disk // and never appears in a URL that a log or a browser history could keep. Start(); using var client = new HttpClient(); var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken); body.ShouldContain(plane.Token); body.ShouldContain( string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket")); body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder); body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder); } [Fact] public async Task ThePage_IsNotCached() { // It carries a connection token. Start(); using var client = new HttpClient(); using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken); response.Headers.CacheControl!.NoStore.ShouldBeTrue(); } [Fact] public async Task AnUnknownPath_Is404() { Start(); using var client = new HttpClient(); using var response = await client.GetAsync( new Uri($"http://127.0.0.1:{plane.Port}/nope"), TestContext.Current.CancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } // ---- Attaching ---- [Fact] public async Task TheRenderer_Attaches() { Start(); using var socket = await ConnectAsync(); socket.State.ShouldBe(WebSocketState.Open); socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol); await plane.RendererAttached; } [Fact] public async Task AConnectionWithoutTheToken_IsRejected() { Start(); await Should.ThrowAsync(async () => await ConnectAsync(token: null)); } [Fact] public async Task AConnectionWithTheWrongToken_IsRejected() { Start(); await Should.ThrowAsync(async () => await ConnectAsync(token: "not-the-token")); } [Fact] public async Task AConnectionFromAnotherOrigin_IsRejected() { // The threat this actually addresses: a page open in the user's browser can attempt WebSocket // connections to loopback ports, and would otherwise reach a terminal. Start(); await Should.ThrowAsync(async () => await ConnectAsync(origin: "https://evil.example")); } [Fact] public async Task ASecondRenderer_IsRejected() { Start(); using var first = await ConnectAsync(); first.State.ShouldBe(WebSocketState.Open); await Should.ThrowAsync(async () => await ConnectAsync()); } // ---- Frames ---- [Fact] public async Task Output_ReachesTheRenderer() { Start(); using var socket = await ConnectAsync(); await using var session = new FakeShellSession(bytesToProduce: 128); await using var pump = CreatePump(session); plane.Register(SessionId, pump); var run = pump.RunAsync(TestContext.Current.CancellationToken); // SessionOpened first, then the output. var opened = await ReceiveAsync(socket); opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened); opened.SessionId.ShouldBe(SessionId); var output = await ReceiveAsync(socket); output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output); output.Payload.Length.ShouldBe(128); // Acknowledge it, exactly as the page does from term.write's callback. await SendAsync( socket, (byte)TerminalClientOpcode.Acknowledge, TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length)); await run; } [Fact] public async Task Input_ReachesTheRemote() { Start(); using var socket = await ConnectAsync(); await using var session = new FakeShellSession(); await using var pump = CreatePump(session); plane.Register(SessionId, pump); await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray()); await WaitUntilAsync(() => session.Written.Length > 0); Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r"); } [Fact] public async Task AResize_ReachesTheRemote() { Start(); using var socket = await ConnectAsync(); await using var session = new FakeShellSession(); await using var pump = CreatePump(session); plane.Register(SessionId, pump); await SendAsync( socket, (byte)TerminalClientOpcode.Resize, TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075))); await WaitUntilAsync(() => session.ResizeCount > 0); session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075)); } [Fact] public async Task AnAcknowledgement_ReturnsCredit() { Start(); using var socket = await ConnectAsync(); await using var session = new FakeShellSession(); await using var pump = CreatePump(session); plane.Register(SessionId, pump); pump.Credits.TryReserve(1024); pump.Credits.Outstanding.ShouldBe(1024); await SendAsync( socket, (byte)TerminalClientOpcode.Acknowledge, TerminalFrame.CreateAcknowledgementPayload(1024)); await WaitUntilAsync(() => pump.Credits.Outstanding == 0); } /// /// The point of this one is the absence of a registered session. Every other client frame is /// about a terminal and is dropped once its session has gone; a text-size chord is about the person /// reading, and the page sends whichever id it had to hand — often one whose shell has just ended, /// which is exactly when somebody may be squinting at the message it left behind. Dispatching this /// opcode before the session lookup is what makes that work. /// [Fact] public async Task AFontSizeStep_IsHeardWithNoSessionRegistered() { Start(); using var socket = await ConnectAsync(); var steps = new List(); plane.FontSizeStepRequested += (_, e) => steps.Add(e.Step); await SendAsync( socket, (byte)TerminalClientOpcode.FontSizeStep, TerminalFrame.CreateFontSizeStepPayload(-1), sessionId: 0); await WaitUntilAsync(() => steps.Count > 0); steps[0].ShouldBe(-1); } [Fact] public async Task AFrameForAnUnregisteredSession_IsIgnored() { // Ordinary during teardown: the page can still have frames in flight for a session that just // ended. Dropping them must not disturb anything else. Start(); using var socket = await ConnectAsync(); await using var session = new FakeShellSession(); await using var pump = CreatePump(session); plane.Register(SessionId, pump); // A session id that is never registered. Using the registered one and relying on ordering // would be a race: frames are dispatched on the receive loop, so the orphan can land after // registration and legitimately be delivered. await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999); await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray()); await WaitUntilAsync(() => session.Written.Length > 0); Encoding.UTF8.GetString(session.Written).ShouldBe("real"); } [Fact] public async Task AMalformedFrame_IsIgnored() { Start(); using var socket = await ConnectAsync(); // Shorter than the header. await socket.SendAsync( new byte[] { 1, 2 }, WebSocketMessageType.Binary, endOfMessage: true, TestContext.Current.CancellationToken); await using var session = new FakeShellSession(); await using var pump = CreatePump(session); plane.Register(SessionId, pump); await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray()); await WaitUntilAsync(() => session.Written.Length > 0); Encoding.UTF8.GetString(session.Written).ShouldBe("still here"); } // ---- Helpers ---- private void Start() => server = plane.RunAsync(lifetime.Token); private TerminalSessionPump CreatePump(FakeShellSession session) => new( SessionId, session, plane, TimeProvider.System, new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) }); private async Task ConnectAsync( string? token = "", string? origin = null) { var socket = new ClientWebSocket(); socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol); if (token is not null) { socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}"); } socket.Options.SetRequestHeader( "Origin", origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}")); try { await socket.ConnectAsync( new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"), TestContext.Current.CancellationToken); } catch { socket.Dispose(); throw; } return socket; } private static async Task SendAsync( ClientWebSocket socket, byte opcode, byte[] payload, uint sessionId = SessionId) => await socket.SendAsync( TerminalFrame.Create(opcode, sessionId, payload), WebSocketMessageType.Binary, endOfMessage: true, TestContext.Current.CancellationToken); private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync( ClientWebSocket socket) { var buffer = new byte[64 * 1024]; var result = await socket.ReceiveAsync( buffer.AsMemory(), TestContext.Current.CancellationToken); TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload) .ShouldBeTrue(); return (opcode, sessionId, payload.ToArray()); } /// /// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive /// loop, which has no completion for the caller to await. The bound keeps a genuine failure a /// failure rather than a hang. /// private static async Task WaitUntilAsync(Func condition) { var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5); while (TimeProvider.System.GetUtcNow() < deadline) { if (condition()) { return; } await Task.Delay(10, TestContext.Current.CancellationToken); } throw new TimeoutException("The expected state was not reached within 5 seconds."); } }