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, progress: null, 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),
progress: null,
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}");
}
}