Files
DodoSSH/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Forwards.cs
jaap-jan f43436218a
ci / build and test (pull_request) Successful in 2m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m42s
Tests: wait for a dropped session's forwards to stop, not just for the tab to go dead
WhenTheSessionEnds_ItsForwardsSayTheyWentWithIt failed once under a full run. Ended clears IsLive
and then tells each forward its session is over, in one call. The app's dispatcher never splits that,
but the harness's post runs it on the fake shell's thread, so the test's poll on !IsLive could return
between the two and find the forwards still listening.

The test now also waits until every forward says "Stopped with the session.", the last thing
SessionEnded sets. A 300 ms sleep injected between the two steps fails the old test and passes the new
one; the full App.Tests suite passed three runs in a row.
2026-10-16 13:17:01 +02:00

343 lines
14 KiB
C#

using DodoSSH.Client.Domain;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Port forwards, from the host editor that saves them to the tab they run on.
/// </summary>
/// <remarks>
/// The listeners here are fakes; <c>PortForwardTests</c> in the SSH suite carries real bytes through all
/// three kinds against a real sshd. What this holds is everything around them: which forwards start, on
/// which session, what a refusal looks like, and that none of it outlives the session.
/// </remarks>
public sealed partial class ShellFlowTests
{
[Fact]
public async Task AHostsForwards_StartWithItsTerminal_AndAreCountedInTheStatusBar()
{
var vault = await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsLive.ShouldBeTrue();
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
tab.Forwards.Select(row => row.Notation).ShouldBe(["-L 5432:db.internal:5432", "-D 1080"]);
tab.ForwardsSummary.ShouldBe("⇄ 2 FORWARDS");
ssh.Forwards.Select(forward => forward.Spec).ShouldBe(
[
new SshPortForwardSpec(SshPortForwardKind.Local, 5432, "db.internal", 5432),
new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080),
]);
vault.SelectedHost.ShouldNotBeNull().HasForwards.ShouldBeTrue();
}
[Fact]
public async Task AForwardThatCannotStart_SaysWhy_AndStopsNothingElse()
{
ssh.ForwardFailure = spec => spec.BindPort == 5432 ? "Port 5432 on this machine is already in use." : null;
await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.Forwards.All(row => row.State is not PortForwardState.Starting), "both should settle");
var refused = tab.Forwards[0];
refused.State.ShouldBe(PortForwardState.Failed);
refused.StateText.ShouldBe("FAILED");
refused.Detail.ShouldBe("Port 5432 on this machine is already in use.");
tab.Forwards[1].IsListening.ShouldBeTrue();
tab.IsLive.ShouldBeTrue("a forward that cannot start is not a reason to lose the shell");
tab.ForwardsSummary.ShouldBe("⇄ 1 FORWARD");
// The switch on a failed row is a retry, and the port has been freed since.
ssh.ForwardFailure = null;
await refused.ToggleCommand.ExecuteAsync(null);
refused.IsListening.ShouldBeTrue();
refused.Detail.ShouldBeNull();
}
[Fact]
public async Task AForwardCanBeSwitchedOffAndOnAgain_WithoutTouchingTheSession()
{
await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
var row = tab.Forwards[0];
var first = ssh.Forwards[0];
await row.ToggleCommand.ExecuteAsync(null);
row.State.ShouldBe(PortForwardState.Stopped);
first.IsDisposed.ShouldBeTrue("switching off is what stops the listener");
tab.ForwardsSummary.ShouldBe("⇄ 1 FORWARD");
await row.ToggleCommand.ExecuteAsync(null);
row.IsListening.ShouldBeTrue();
ssh.Forwards.Count.ShouldBe(3, "a restart is a new listener on the same session");
tab.IsLive.ShouldBeTrue();
}
[Fact]
public async Task AFailedConnectionThroughAForward_IsShownUnderIt_WhileItKeepsListening()
{
await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
ssh.Forwards[0].Fail("Connection refused");
tab.Forwards[0].Detail.ShouldBe("Connection refused");
tab.Forwards[0].IsListening.ShouldBeTrue();
}
[Fact]
public async Task WhenTheSessionEnds_ItsForwardsSayTheyWentWithIt()
{
await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
ssh.Shells.ShouldHaveSingleItem().Drop("An established connection was aborted by the server.");
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
await EventuallyAsync(
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
"the session's end should have reached its forwards");
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
tab.ForwardsSummary.ShouldBeNull();
ssh.Forwards.ShouldAllBe(forward => forward.IsDisposed);
}
[Fact]
public async Task AReconnect_StartsTheForwardsAgainOnTheNewSession()
{
await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
var before = tab.Forwards.ToList();
ssh.Shells[0].Drop("An established connection was aborted by the server.");
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
await shell.ReconnectTabCommand.ExecuteAsync(tab);
await EventuallyAsync(() => tab.IsLive && tab.ListeningForwardCount == 2, "the new session's forwards should listen");
tab.Forwards.ShouldNotContain(before[0], "a reconnect is a new session, with new rows");
ssh.Forwards.Count.ShouldBe(4);
}
[Fact]
public async Task ATypedInAddress_StartsNoForwards()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@10.0.0.9";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
ssh.Forwards.ShouldBeEmpty();
}
// ---- The phone's sheet ----
/// <remarks>
/// The phone lists a tab's forwards in a sheet over the terminal, so it obeys the connect sheet's rules:
/// it collapses the renderer while it is up, and leaving the terminal lowers it however it is left.
/// </remarks>
[Fact]
public async Task TheForwardsSheet_HidesTheRenderer_AndLeavingTheTerminalLowersIt()
{
await ConnectedHostWithForwardsAsync();
shell.SelectedTab.ShouldNotBeNull().HasForwards.ShouldBeTrue();
shell.OpenForwardsSheetCommand.Execute(null);
shell.IsForwardsSheetOpen.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
shell.CloseForwardsSheetCommand.Execute(null);
shell.IsTerminalShowing.ShouldBeTrue();
shell.OpenForwardsSheetCommand.Execute(null);
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.IsForwardsSheetOpen.ShouldBeFalse("a sheet left up over a page would hold the renderer blank");
shell.OpenForwardsSheetCommand.Execute(null);
shell.IsForwardsSheetOpen.ShouldBeFalse("it must not open over a page either");
}
/// <remarks>
/// What the phone's bar button and its keep-alive notification read: whether a tab has forwards, whether
/// one failed, and how many listen across every tab — with an event each time any of that moves.
/// </remarks>
[Fact]
public async Task TheCountsAPhoneShows_FollowEveryForwardsState()
{
ssh.ForwardFailure = spec => spec.BindPort == 5432 ? "Port 5432 on this machine is already in use." : null;
var changes = 0;
shell.ForwardsChanged += (_, _) => changes++;
await ConnectedHostWithForwardsAsync();
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.Forwards.All(row => row.State is not PortForwardState.Starting), "both should settle");
tab.HasForwards.ShouldBeTrue();
tab.HasFailedForward.ShouldBeTrue();
tab.HasListeningForward.ShouldBeTrue();
shell.ListeningForwardCount.ShouldBe(1);
changes.ShouldBeGreaterThan(0);
var before = changes;
ssh.ForwardFailure = null;
await tab.Forwards[0].ToggleCommand.ExecuteAsync(null);
tab.HasFailedForward.ShouldBeFalse();
shell.ListeningForwardCount.ShouldBe(2);
changes.ShouldBeGreaterThan(before, "a retry that listens is a change the notification has to hear");
ssh.Shells.ShouldHaveSingleItem().Drop("An established connection was aborted by the server.");
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
shell.ListeningForwardCount.ShouldBe(0);
tab.HasListeningForward.ShouldBeFalse();
tab.HasForwards.ShouldBeTrue("the rows stay, saying they went with the session");
}
// ---- The editor ----
[Fact]
public async Task TheEditor_RefusesASecondListenerOnAPortThisHostAlreadyListensOn()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
AddForward(vault, PortForwardKind.Local, "8080", "web.internal:80");
AddForward(vault, PortForwardKind.Dynamic, "8080", string.Empty);
vault.Status.ShouldBe("This host already has a forward listening on port 8080 here.");
vault.EditorPortForwards.Count.ShouldBe(1);
// A remote forward listens on the remote, whose ports are a different set.
AddForward(vault, PortForwardKind.Remote, "8080", "localhost:3000");
vault.EditorPortForwards.Count.ShouldBe(2);
}
[Theory]
[InlineData("", "db:5432", "A forward needs a listening port, as a number.")]
[InlineData("-1", "db:5432", "A forward needs a listening port, as a number.")]
[InlineData("70000", "db:5432", "A forward's listening port must be between 1 and 65535, not 70000.")]
[InlineData("5432", "db", "A forward's target is host:port, for example db.internal:5432.")]
[InlineData("5432", "::1:5432", "A forward's target is host:port, for example db.internal:5432.")]
[InlineData("5432", "db:0", "A forward's target port must be between 1 and 65535, not 0.")]
public async Task TheEditor_RefusesAnUnusableForwardAtTheBox(string bindPort, string target, string expected)
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
AddForward(vault, PortForwardKind.Local, bindPort, target);
vault.Status.ShouldBe(expected);
vault.EditorPortForwards.ShouldBeEmpty();
}
[Theory]
[InlineData("db.internal:5432", "db.internal", 5432)]
[InlineData(" localhost:3000 ", "localhost", 3000)]
[InlineData("[::1]:5432", "::1", 5432)]
[InlineData("10.0.0.9:22", "10.0.0.9", 22)]
public void AForwardTarget_IsReadAsHostColonPort(string text, string host, int port)
{
VaultViewModel.TryParseForwardTarget(text, out var parsedHost, out var parsedPort).ShouldBeTrue();
parsedHost.ShouldBe(host);
parsedPort.ShouldBe(port);
}
[Fact]
public async Task EditingAHost_KeepsItsForwardsIds_SoTheMergeSeesAnEditNotANewForward()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
AddForward(vault, PortForwardKind.Local, "5432", "db.internal:5432");
await vault.SaveHostCommand.ExecuteAsync(null);
var saved = vault.Hosts[0].Host.PortForwards.ShouldHaveSingleItem();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNotes = "primary";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts[0].Host.PortForwards.ShouldHaveSingleItem().ShouldBe(saved);
}
/// <summary>
/// A host with a bound key and two forwards — one local, one SOCKS — connected, with its tab selected.
/// </summary>
private async Task<VaultViewModel> ConnectedHostWithForwardsAsync()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.EditSelectedHostCommand.Execute(null);
AddForward(vault, PortForwardKind.Local, "5432", "db.internal:5432");
AddForward(vault, PortForwardKind.Dynamic, "1080", string.Empty);
vault.EditorPortForwards.Count.ShouldBe(2, vault.Status);
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
return vault;
}
private static void AddForward(VaultViewModel vault, PortForwardKind kind, string bindPort, string target)
{
vault.EditorNewForwardKind = (int)kind;
vault.EditorNewForwardBindPort = bindPort;
vault.EditorNewForwardTarget = target;
vault.AddEditorForwardCommand.Execute(null);
}
}