using DodoSSH.Client.Domain;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.App.Tests;
///
/// Port forwards, from the host editor that saves them to the tab they run on.
///
///
/// The listeners here are fakes; PortForwardTests 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.
///
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");
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 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);
}
///
/// A host with a bound key and two forwards — one local, one SOCKS — connected, with its tab selected.
///
private async Task 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);
}
}