using System.Globalization; using System.Net.WebSockets; using System.Text; using DodoSSH.Client.Terminal; namespace DodoSSH.Client.Ssh.Tests; /// /// A real SSH session, through the real data plane, to a stand-in renderer. /// /// /// /// Everything the desktop client does when a user opens a terminal, minus the pixels: a real /// sshd in a container, a real pseudo-terminal, the real loopback WebSocket with its token and /// origin checks, and a standing in for the page. If a shell prompt /// arrives here and typed input round-trips, the only untested link left is xterm drawing bytes it was /// handed. /// /// /// Worth having because the alternative is driving a GUI. The WebView's own participation is /// verifiable separately — it opens a TCP connection to this same port — but that says nothing about /// whether an SSH session's output reaches it. /// /// [Collection(SshCollection.Name)] public sealed class TerminalEndToEndTests(SshServerFixture fixture) { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); /// /// The real page is an Avalonia resource in the app project. This test stands in for the renderer /// itself, so a placeholder-bearing stub is all the transport needs. /// private static InMemoryTerminalAssetProvider StubAssets() => new(new Dictionary(StringComparer.Ordinal) { [TerminalDataPlane.PagePath] = new( "text/html; charset=utf-8", Encoding.UTF8.GetBytes( $"")), }); [Fact] public async Task AShellSessionReachesTheRenderer_AndInputReachesTheRemote() { var knownHosts = new InMemoryKnownHostStore(); await using var workspace = new TerminalWorkspace( StubAssets(), new SshNetConnectionFactory(knownHosts), TimeProvider.System); workspace.Start(); // The token comes from the served page, exactly as the real renderer obtains it. var token = await ReadTokenAsync(workspace.PageUrl); using var renderer = await AttachAsync(workspace.PageUrl, token); await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken); var sessionId = await OpenTrustedSessionAsync(workspace, knownHosts); // SessionOpened tells the renderer to create a terminal before any output arrives for it. var opened = await ReceiveAsync(renderer); opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened); opened.SessionId.ShouldBe(sessionId); // The login banner and prompt arrive unprompted, acknowledged as the page does from // term.write's callback. var banner = await ReadOutputUntilAsync(renderer, sessionId, "$", acknowledge: true); banner.ShouldContain("OpenSSH"); // Marker split so the PTY's echo of the command line does not satisfy the match. await SendAsync( renderer, sessionId, (byte)TerminalClientOpcode.Input, Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"; stty size\n")); var output = await ReadOutputUntilAsync(renderer, sessionId, "DODOSSH-OK", acknowledge: true); output.ShouldContain("DODOSSH-OK"); // The size requested when the session opened is the size the remote sees, which means the // pty-req carried it rather than the terminal silently defaulting to 80x24. output.Replace('\r', '\n').ShouldContain("30 100"); await workspace.CloseSessionAsync(sessionId); } // ---- Helpers ---- /// /// Trusts the container's host key, then opens a session. /// /// /// The refused first attempt is part of the assertion, not setup noise: a host with no pinned key /// must not connect, and the fingerprint the user would be shown has to be in the exception. /// private async Task OpenTrustedSessionAsync( TerminalWorkspace workspace, InMemoryKnownHostStore knownHosts) { var request = new SshConnectionRequest( fixture.Host, fixture.Port, SshServerFixture.Username, new SshPasswordCredential(SshServerFixture.Password)); var unknown = await Should.ThrowAsync(async () => await workspace.OpenSessionAsync( request, TerminalSize.Default, TestContext.Current.CancellationToken)); unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix); await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken); return await workspace.OpenSessionAsync( request, new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken); } private static async Task ReadTokenAsync(Uri pageUrl) { using var client = new HttpClient(); var page = await client.GetStringAsync(pageUrl, TestContext.Current.CancellationToken); const string Marker = "data-token=\""; var start = page.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length; var end = page.IndexOf('"', start); return page[start..end]; } private static async Task AttachAsync(Uri pageUrl, string token) { var socket = new ClientWebSocket(); socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol); socket.Options.AddSubProtocol($"token.{token}"); socket.Options.SetRequestHeader( "Origin", string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{pageUrl.Port}")); try { await socket.ConnectAsync( new Uri($"ws://127.0.0.1:{pageUrl.Port}{TerminalDataPlane.SocketPath}"), TestContext.Current.CancellationToken); } catch { socket.Dispose(); throw; } return socket; } private static Task SendAsync( ClientWebSocket socket, uint sessionId, byte opcode, byte[] payload) => socket.SendAsync( TerminalFrame.Create(opcode, sessionId, payload), WebSocketMessageType.Binary, endOfMessage: true, TestContext.Current.CancellationToken); /// /// Bounded by its own timeout rather than relying on a caller's deadline. A blocking receive is /// where a missing frame turns into a hung test run instead of a failure with a message, and a hang /// tells you nothing about which frame never came. /// private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync( ClientWebSocket socket) { var buffer = new byte[256 * 1024]; using var deadline = new CancellationTokenSource(Timeout); using var linked = CancellationTokenSource.CreateLinkedTokenSource( deadline.Token, TestContext.Current.CancellationToken); WebSocketReceiveResult result; try { result = await socket.ReceiveAsync(buffer, linked.Token); } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { throw new TimeoutException($"No terminal frame arrived within {Timeout}."); } TerminalFrame.TryRead( buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload) .ShouldBeTrue(); return (opcode, sessionId, payload.ToArray()); } /// Reads output frames until the text appears, acknowledging each as the page does. private static async Task ReadOutputUntilAsync( ClientWebSocket socket, uint sessionId, string expected, bool acknowledge) { var accumulated = new StringBuilder(); var deadline = TimeProvider.System.GetUtcNow() + Timeout; while (TimeProvider.System.GetUtcNow() < deadline) { var frame = await ReceiveAsync(socket); if (frame.Opcode == (byte)TerminalServerOpcode.SessionClosed) { throw new InvalidOperationException( $"The session closed before '{expected}' arrived: " + $"{Encoding.UTF8.GetString(frame.Payload)}\nSeen so far:\n{accumulated}"); } if (frame.Opcode != (byte)TerminalServerOpcode.Output) { continue; } if (acknowledge) { // Returning credit is what keeps the pump reading. Without it the session stalls at // the window size and this loop would time out on a working implementation. await SendAsync( socket, sessionId, (byte)TerminalClientOpcode.Acknowledge, TerminalFrame.CreateAcknowledgementPayload((uint)frame.Payload.Length)); } accumulated.Append(Encoding.UTF8.GetString(frame.Payload)); if (accumulated.ToString().Contains(expected, StringComparison.Ordinal)) { return accumulated.ToString(); } } throw new TimeoutException($"'{expected}' did not arrive within {Timeout}.\n{accumulated}"); } }