Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
T
jaap-jan 7a3a521c59
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s
Give the phone the rest of its screens, and a way in
All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
2026-07-31 21:43:11 +02:00

368 lines
15 KiB
C#

using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// What the workspace waits for outside the process, and how long a session lives once it has one.
/// </summary>
/// <remarks>
/// <para>
/// The renderer gate itself is not in question. <see cref="TerminalDataPlane.SendAsync"/> drops frames when
/// nothing is attached, so a session opened before the renderer arrives loses its <c>SessionOpened</c> frame
/// and streams output at a terminal that was never created — and that it opens when a renderer does attach
/// is covered by <see cref="TerminalDataPlaneTests.TheRenderer_Attaches"/>. What is worth a test here is the
/// half that used to be missing: waiting for a renderer that never arrives has to end.
/// </para>
/// <para>
/// The session count is here for a different reason. It is shown to a user on the unlock screen, as the
/// disclosure that locking the vault leaves shells running, and a wrong number there is not a cosmetic bug:
/// it either hides a live connection or invents one.
/// </para>
/// </remarks>
public sealed class TerminalWorkspaceTests
{
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
{
// The shipped failure this stands in for is a WebView2 that never initialises — no Evergreen
// runtime, an install blocked by policy, an AppContainer that cannot reach loopback. From this
// side they are identical and all look like the listener being up with nothing ever connecting
// to it. Before the wait was bounded this test would have hung instead of failing.
await using var workspace = CreateWorkspace(
new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMilliseconds(250));
workspace.Start();
await Should.ThrowAsync<TimeoutException>(async () =>
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task WaitingForARenderer_ObeysItsCancellationToken()
{
// The timeout is the backstop; the caller's token is what makes a Connect the user gave up on
// return at once rather than sitting out the rest of the wait. The timeout here is long enough
// that only cancellation can end this.
await using var workspace = CreateWorkspace(
new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMinutes(5));
workspace.Start();
using var cancellation = new CancellationTokenSource();
var wait = workspace.WaitForRendererAsync(cancellation.Token);
await cancellation.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(async () => await wait);
}
[Fact]
public async Task AnOpenSessionIsReportedAsLive()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
workspace.LiveSessionCount.ShouldBe(0);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
workspace.LiveSessionCount.ShouldBe(1);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
workspace.LiveSessionCount.ShouldBe(2);
}
/// <remarks>
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
/// closes the channel by itself — the session is still in the dictionary, holding a connection that
/// is finished — so counting entries would report a shell that exited as still running. A user
/// deciding whether it is safe to walk away is the person that lie is told to.
/// </remarks>
[Fact]
public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive()
{
// A shell with no output to give: its first read returns 0, which is a remote closing the
// channel, so the pump finishes on its own with nobody asking it to.
var connections = new FakeConnectionFactory(bytesPerShell: 0);
await using var workspace = CreateWorkspace(connections);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
}
[Fact]
public async Task ClosingASessionEndsItAndDisposesItsConnection()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
workspace.LiveSessionCount.ShouldBe(0);
connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue();
}
/// <remarks>
/// <para>
/// Per-session liveness, which the tab strip's status dot is. The aggregate count answers "may I walk
/// away"; this answers "is <em>this</em> tab still connected", and a tab that went on claiming a shell
/// it no longer has would be the same lie one row down.
/// </para>
/// <para>
/// The unknown-id case is asserted because it is what a stale tab asks. A session id this workspace
/// never issued, or has already closed, is not live — it must not throw and must not be optimistic.
/// </para>
/// </remarks>
[Fact]
public async Task LivenessIsAnsweredPerSession()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var first = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
var second = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
workspace.IsSessionLive(first).ShouldBeTrue();
workspace.IsSessionLive(second).ShouldBeTrue();
workspace.IsSessionLive(9999).ShouldBeFalse("this workspace never issued that id");
await workspace.CloseSessionAsync(first);
workspace.IsSessionLive(first).ShouldBeFalse();
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
}
/// <remarks>
/// <para>
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
/// frames for a session nothing is listening to, so a send at a dead tab succeeds exactly as loudly as a
/// send at a live one. That is why <c>PasteAsync</c> answers rather than returning void — and why the
/// answer is a <see cref="bool"/> and not an exception, since a tab whose remote hung up an hour ago is
/// still on screen and still clickable.
/// </para>
/// <para>
/// What the renderer does with the frame — bracketed paste, and the Enter deliberately outside it —
/// cannot be reached from here at all. It is JavaScript inside a WebView, and it is in
/// <c>docs/manual-checks.md</c> for that reason.
/// </para>
/// </remarks>
[Fact]
public async Task PastingIntoADeadSessionSaysSoRatherThanDroppingIt()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
(await workspace.PasteAsync(
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
.ShouldBeTrue("the session is live");
(await workspace.PasteAsync(
9999, "uptime", execute: false, TestContext.Current.CancellationToken))
.ShouldBeFalse("this workspace never issued that id");
await workspace.CloseSessionAsync(sessionId);
(await workspace.PasteAsync(
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
.ShouldBeFalse("the tab it names is gone");
}
/// <remarks>
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
/// closed has a caller who already knows, and telling it would turn one close into two.
/// </remarks>
[Fact]
public async Task ASessionEndingOnItsOwnIsAnnounced()
{
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
// so the pump finishes with nobody asking it to.
var connections = new FakeConnectionFactory(bytesPerShell: 0);
await using var workspace = CreateWorkspace(connections);
var ended = new List<uint>();
workspace.SessionEnded += (_, e) =>
{
lock (ended)
{
ended.Add(e.SessionId);
}
};
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await WaitUntilAsync(() =>
{
lock (ended)
{
return ended.Contains(sessionId);
}
});
}
/// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
[Fact]
public async Task ClosingASessionIsNotAnnouncedBack()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var announcements = 0;
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
Volatile.Read(ref announcements)
.ShouldBe(0, "a close the caller asked for is not news to report back to it");
}
/// <remarks>
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
/// that the SSH connections are known to be released rather than assumed to be.
/// </remarks>
[Fact]
public async Task DisposingTheWorkspaceClosesEverySession()
{
var connections = new FakeConnectionFactory();
var workspace = CreateWorkspace(connections);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.DisposeAsync();
workspace.LiveSessionCount.ShouldBe(0);
connections.Connections.Count.ShouldBe(2);
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
}
// ---- Helpers ----
/// <remarks>
/// <para>
/// Input that did not come from the keyboard. The Android head's accessory key row is what needs this —
/// a software keyboard has no Ctrl, Esc, Tab or arrows — and what it sends has to arrive at the remote
/// byte for byte, because an escape sequence that loses a byte is not a degraded arrow key, it is a
/// stray character in somebody's shell.
/// </para>
/// <para>
/// Ordinary typing does not come this way and is not what is being tested: that goes from the renderer
/// down the socket, which the pump's own tests cover.
/// </para>
/// </remarks>
[Fact]
public async Task SendingInput_ReachesTheRemoteUnchanged()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
await workspace.SendInputAsync(sessionId, upArrow, TestContext.Current.CancellationToken);
await WaitUntilAsync(() =>
connections.Connections.SingleOrDefault()?.Shell?.Written.SequenceEqual(upArrow) == true);
}
/// <remarks>
/// A tab can close while a key is still in flight, which on a phone is one mis-tap rather than a rare
/// race — the close cross sits inside the tab and the accessory row is directly under it. Throwing
/// would turn that into a crash on a keystroke that no longer matters.
/// </remarks>
[Fact]
public async Task SendingInputToASessionThatIsGone_IsIgnored()
{
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
await Should.NotThrowAsync(async () =>
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
}
/// <param name="connections">
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
/// take a fake anyway, because reaching a real host from here would make this a network test.
/// </param>
/// <param name="rendererTimeout">The gate's bound, or null for the shipped default.</param>
private static TerminalWorkspace CreateWorkspace(
ISshConnectionFactory connections,
TimeSpan? rendererTimeout = null) =>
new(
StubAssets(),
connections,
TimeProvider.System,
rendererTimeout is { } timeout
? new TerminalWorkspaceOptions { RendererTimeout = timeout }
: null);
private static InMemoryTerminalAssetProvider StubAssets() =>
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
});
private static SshConnectionRequest Request() =>
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
/// <remarks>
/// Polled rather than awaited on a task, because the point is what an observer of the property
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
/// telling it to.
/// </remarks>
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = TimeProvider.System.GetUtcNow() + PollTimeout;
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(20, TestContext.Current.CancellationToken);
}
throw new TimeoutException($"The condition was still false after {PollTimeout}.");
}
}