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"));
}
///
/// The truth this replaced: a second valid attach used to be a 409, on the theory that one renderer
/// lives for the whole process. Android's WebView does not honour that theory — its renderer process is
/// routinely killed and the page reloads with a fresh socket — so a second valid attach is now a
/// takeover. This asserts both halves: the newcomer gets the connection, and the displaced socket
/// actually goes rather than lingering as a phantom nothing is reading from.
///
[Fact]
public async Task ASecondRenderer_TakesOver_AndTheFirstSocketIsDropped()
{
Start();
using var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open);
using var second = await ConnectAsync();
second.State.ShouldBe(WebSocketState.Open);
// The first socket was aborted rather than closed gracefully — Abort skips the close handshake
// entirely, so there is no Close frame for this side to see coming. What a receive on it sees
// instead is the connection simply gone, which the client surfaces as an exception rather than as
// a state that quietly flips on its own; nothing here reads from the socket otherwise, so the
// state alone would not move.
var firstBuffer = new byte[16];
await Should.ThrowAsync(async () =>
await first.ReceiveAsync(firstBuffer.AsMemory(), TestContext.Current.CancellationToken));
await using var session = new FakeShellSession(bytesToProduce: 64);
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var run = pump.RunAsync(TestContext.Current.CancellationToken);
var opened = await ReceiveAsync(second);
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
var output = await ReceiveAsync(second);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
output.Payload.Length.ShouldBe(64);
await SendAsync(
second,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
await run;
}
///
///
/// The other half of the takeover: a renderer process that dies without a close handshake — which is
/// what a killed Android WebView actually does, no FIN, nothing — must not fault the send path. A
/// faulted send would propagate into 's flush loop and freeze a live
/// session; see 's remark for why. Disposing the client socket
/// abruptly, with no close handshake sent, is the closest this harness gets to that: the server-side
/// socket is left believing itself open until it actually tries to write to it.
///
///
/// Driven straight through rather than through a pump, because
/// a pump adds nothing here — the point is entirely about the transport's own contract, and a session
/// layered on top would only leave it unclear whether a passing test proved the transport never threw or
/// merely that the frames never happened to need a live socket.
///
///
[Fact]
public async Task SendAsync_DoesNotThrow_WhenTheAttachedRendererDiedWithoutClosing_AndAFreshAttachStillReceives()
{
Start();
var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open);
first.Dispose();
// Whether this particular send lands on the OS's send buffer before the peer's absence is noticed,
// or fails immediately, is not the point — either way it must not throw.
await Should.NotThrowAsync(async () =>
await plane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "before"u8.ToArray()),
TestContext.Current.CancellationToken));
using var second = await ConnectAsync();
await Should.NotThrowAsync(async () =>
await plane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "after"u8.ToArray()),
TestContext.Current.CancellationToken));
var output = await ReceiveAsync(second);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
Encoding.UTF8.GetString(output.Payload).ShouldBe("after");
}
// ---- 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.");
}
}