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"); // 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 ---- /// /// 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. /// [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"); } /// /// 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. /// [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); } /// /// 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); } }