using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Text; using DodoSSH.Client.Auth; using DodoSSH.Client.Session; using DodoSSH.Client.Storage; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.App.Tests; /// /// A host reached through Spelunk, from the editor and from a link to the request that reaches SSH. /// /// /// /// The tunnel is stood in for at , and what it hands back is a real /// over the relay's real bridge and an in-process WebSocket pair — so the /// proxy SSH is given, and the close code a failed connect is explained by, are the real ones. The tunnel's /// own HTTP and WebSocket half is SpelunkTunnelTests' job. /// /// /// What these hold is what the tick box, the sign-in and the link promise: a Spelunk host is never dialled /// directly and never probed, a refusal is said in Spelunk's terms, this device's sign-in to a Spelunk goes /// only to that Spelunk and only once DodoSSH's own account has been checked, a sign-in connects with the /// tunnel's key and Spelunk's account and nothing of the host's, and a link connects straight away but never /// starts a sign-in by itself. /// /// public sealed partial class ShellFlowTests { private const string SpelunkApi = "https://api.spelunk.example.com"; private const string SpelunkToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"); private readonly FakeSpelunkTunnel spelunkTunnels = new(); private readonly FakeSpelunkAccounts spelunkAccounts = new(); [Fact] public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself() { var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); // This device's sign-in, to the Spelunk and server the host names, for the host's own port — and only // once DodoSSH's own account has been checked. var opened = spelunkTunnels.Opened.ShouldHaveSingleItem(); opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22)); server.SignInChecks.ShouldBeGreaterThan(0); // Through the loopback proxy, and named as the host, because the name is what its key is pinned under. var request = ssh.Requests.ShouldHaveSingleItem(); request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType(); request.Host.ShouldBe("203.0.113.10"); shell.IsTerminalShowing.ShouldBeTrue(); await request.Proxy.Lifetime.DisposeAsync(); } [Fact] public async Task ARefusedTunnel_IsExplained_AndNothingIsDialledOrProbed() { spelunkTunnels.Refusal = new SpelunkTunnelException( "The Spelunk agent on this server is not connected, so Spelunk cannot reach it."); var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); // No fallback to the address. For a Spelunk host it may be nothing that answers at all. ssh.Requests.ShouldBeEmpty(); reachability.Probed.ShouldBeEmpty(); vault.Status.ShouldNotBeNull().ShouldContain("agent on this server is not connected"); } [Fact] public async Task ATunnelWhoseAgentCouldNotReachThePort_SaysSo_InsteadOfTheTransportError() { // What Spelunk does when the agent's own connect to 127.0.0.1:22 fails: accept, then close with 4502. spelunkTunnels.CloseWith = (4502, "dial tcp 127.0.0.1:22: connection refused"); ssh.BeforeConnect = request => ThroughSocksToTheEndAsync(request.Proxy.ShouldNotBeNull().Port); ssh.Failure = new InvalidOperationException("An established connection was aborted by the server."); var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldNotBeNull().ShouldContain("could not connect to port 22 on the server itself"); vault.Status.ShouldNotContain("aborted"); reachability.Probed.ShouldBeEmpty(); } [Fact] public async Task ASpelunkHostsFileTransfer_GoesThroughTheTunnelToo() { var vault = await ReadyToConnectThroughSpelunkAsync(); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldHaveSingleItem(); var request = ssh.SftpRequests.ShouldHaveSingleItem(); request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType(); await request.Proxy.Lifetime.DisposeAsync(); } [Fact] public async Task TheRelayAndSpelunk_AreOneChoice_InTheEditor() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.EditorRelayEnabled = true; vault.EditorThroughSpelunk = true; vault.EditorRelayEnabled.ShouldBeFalse("ticking Spelunk unticks the relay"); vault.EditorRelayEnabled = true; vault.EditorThroughSpelunk.ShouldBeFalse("and the other way round"); } [Fact] public async Task AHostOnASpelunkThisDeviceIsNotSignedInTo_IsRefusedAtConnect_AndOffersTheSignIn() { var vault = await ReadyToConnectThroughSpelunkAsync(); await vault.Session.SpelunkSignIns.ForgetAllAsync(Token); await vault.ReloadSpelunkSignInsAsync(Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); vault.Status.ShouldNotBeNull().ShouldContain("not signed in there"); vault.EditSelectedHostCommand.Execute(null); vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); vault.EditorSpelunkSignInNote.ShouldContain("not signed in to Spelunk"); } [Fact] public async Task ASavedSpelunkHost_OpensInTheEditorWithItsRoute() { var vault = await ReadyToConnectThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorThroughSpelunk.ShouldBeTrue(); vault.EditorSpelunkApi.ShouldBe(SpelunkApi); vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString()); vault.EditorSpelunkNeedsSignIn.ShouldBeFalse(); vault.EditorSpelunkSignInNote.ShouldContain("ops@example.com"); } // ---- Signing in through Spelunk ---- [Fact] public async Task ASignInHost_ConnectsWithAFreshKeyAndSpelunksAccount_AndNoLoginOfItsOwn() { spelunkTunnels.User = "ops"; var vault = await ReadyToSignInThroughSpelunkAsync(); vault.SelectedHostAsksForAPassword.ShouldBeFalse("there is nothing to type"); vault.SelectedHostAuthenticationNote.ShouldContain("Spelunk signs you in"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22)); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 "); // The account Spelunk named and the key the tunnel was opened with, through the tunnel, as the host. var request = ssh.Requests.ShouldHaveSingleItem(); request.Username.ShouldBe("ops"); request.Credential.ShouldBeSameAs(signIn.Credential); request.Host.ShouldBe("203.0.113.10"); request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType(); shell.IsTerminalShowing.ShouldBeTrue(); shell.Tabs.ShouldHaveSingleItem(); vault.Credentials.ShouldBeEmpty("nothing is remembered for a sign-in"); await request.Proxy.Lifetime.DisposeAsync(); } [Fact] public async Task ASignInHostsForwards_StillStart_OverTheSignedInSession() { var vault = await ReadyToSignInThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewForwardKind = (int)Domain.PortForwardKind.Dynamic; vault.EditorNewForwardBindPort = "1080"; vault.EditorNewForwardTarget = string.Empty; vault.AddEditorForwardCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); await EventuallyAsync(() => tab.ListeningForwardCount == 1, "the forward should be listening"); ssh.Forwards.ShouldHaveSingleItem().Spec.ShouldBe(new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080)); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASignInHostsFileTransfer_SignsInThroughSpelunkToo() { var vault = await ReadyToSignInThroughSpelunkAsync(); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; shell.Transfers.SelectedHostAsksForAPassword.ShouldBeFalse(); await shell.Transfers.ConnectCommand.ExecuteAsync(null); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); var request = ssh.SftpRequests.ShouldHaveSingleItem(); request.Username.ShouldBe("root"); request.Credential.ShouldBeSameAs(signIn.Credential); request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType(); shell.Transfers.ConnectedTo.ShouldStartWith("root@203.0.113.10"); await request.Proxy.Lifetime.DisposeAsync(); } [Fact] public async Task ASpelunkHostThatDoesNotSignIn_StillNeedsALogin() { // The sign-in is what lets a host connect with no binding; without it, the old rule stands. var vault = await ReadyToSignInThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorSpelunkSignIn = false; await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.Single(); vault.SelectedHostAsksForAPassword.ShouldBeTrue(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldNotBeNull().ShouldContain("no username"); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); } [Fact] public async Task TickingSignIn_GreysOutTheLogin_AndTheSavedHostBindsNothing() { var vault = await ReadyToConnectThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorAsksForLogin.ShouldBeTrue(); vault.EditorSpelunkSignIn = true; vault.EditorAsksForLogin.ShouldBeFalse(); // The choice is still there to see, and to come back to, but it is not saved beside the sign-in. vault.EditorSelectedAuthentication.ShouldNotBeNull().Label.ShouldBe("deploy password"); await vault.SaveHostCommand.ExecuteAsync(null); var host = vault.Hosts.ShouldHaveSingleItem().Host; host.SignsInThroughSpelunk.ShouldBeTrue(); host.CredentialId.ShouldBeNull(); host.SshKeyId.ShouldBeNull(); host.AsksForPassword.ShouldBeNull(); // Unticking the route altogether gives the login back. vault.EditSelectedHostCommand.Execute(null); vault.EditorSpelunkSignIn.ShouldBeTrue(); vault.EditorThroughSpelunk = false; vault.EditorAsksForLogin.ShouldBeTrue(); } // ---- A link from Spelunk ---- [Fact] public async Task ALinkForAServerWithNoHost_OpensTheEditor_AndConnectsNothing() { await UnlockedAsync(); var vault = shell.Vault!; shell.OpenLink(Link(host: "203.0.113.10")); vault.IsEditing.ShouldBeTrue(); vault.EditorLabel.ShouldBe("web 01"); vault.EditorHostname.ShouldBe("203.0.113.10"); vault.EditorPort.ShouldBe(22); vault.EditorThroughSpelunk.ShouldBeTrue(); vault.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk"); vault.EditorAsksForLogin.ShouldBeFalse(); vault.EditorSpelunkApi.ShouldBe(SpelunkApi); vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString()); // Not signed in to that Spelunk, and a link never starts a sign-in by itself: the editor offers it. vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); vault.Status.ShouldNotBeNull().ShouldContain("Sign in to that Spelunk"); spelunkAccounts.SignedIn.ShouldBeEmpty(); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it"); } [Fact] public async Task ALinkWithNoAddress_NamesTheHostAfterTheServer() { await UnlockedAsync(); shell.OpenLink(Link(host: null)); shell.Vault!.EditorHostname.ShouldBe($"spelunk-{SpelunkServer}"); } [Fact] public async Task TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault() { await UnlockedAsync(); var vault = shell.Vault!; await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.IsEditing.ShouldBeTrue(); vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal.ShouldBeTrue(); vault.EditorTagChoices.ShouldHaveSingleItem().ShouldSatisfyAllConditions( chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag), chip => chip.IsWorn.ShouldBeTrue()); // Signed in from the editor and saved, the next link for that server finds it rather than adding a // second one. await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null); spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]); vault.EditorSpelunkNeedsSignIn.ShouldBeFalse(); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.Hosts.ShouldHaveSingleItem(); spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor() { var vault = await ReadyToConnectThroughSpelunkAsync(); var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: null, server: other)); vault.IsEditing.ShouldBeFalse("nothing to choose: this device is signed in to this Spelunk"); // Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk, and no // login of its own. var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other); added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId); added.Label.ShouldBe("web 01"); added.Host.Hostname.ShouldBe($"spelunk-{other}"); added.Host.Port.ShouldBe(22); added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi); added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue(); added.Host.SshKeyId.ShouldBeNull(); added.Host.CredentialId.ShouldBeNull(); added.Host.Username.ShouldBeNull(); added.Host.TagIds.ShouldHaveSingleItem().ShouldBe(vault.Tags.Single(tag => tag.Label == VaultViewModel.SpelunkTag).EntityId); // And connected, through Spelunk, signing in. var opened = spelunkTunnels.Opened.ShouldHaveSingleItem(); opened.ShouldBe((SpelunkApi, other, SpelunkToken, 22)); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); var request = ssh.Requests.ShouldHaveSingleItem(); request.Username.ShouldBe("root"); request.Credential.ShouldBeSameAs(signIn.Credential); shell.IsTerminalShowing.ShouldBeTrue(); await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASecondLinkForTheSameServer_ConnectsToTheHostTheFirstAdded() { var vault = await ReadyToConnectThroughSpelunkAsync(); var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(server: other)); await vault.OpenSpelunkLinkAsync(Link(server: other)); vault.Hosts.Count(row => row.Host.Spelunk?.ServerId == other).ShouldBe(1); spelunkTunnels.Opened.Count.ShouldBe(2); // The two SignIns are two keys: one per connection, never reused. spelunkTunnels.SignIns[0].ShouldNotBeNull().PublicKeyLine .ShouldNotBe(spelunkTunnels.SignIns[1].ShouldNotBeNull().PublicKeyLine); foreach (var request in ssh.Requests) { await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } } [Fact] public async Task ALinkForATaggedHostInThePersonalVault_ConnectsToItAsSaved() { var vault = await ReadyToConnectThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewTag = VaultViewModel.SpelunkTag; await vault.AddEditorTagCommand.ExecuteAsync(null); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7")); vault.IsEditing.ShouldBeFalse(); vault.Hosts.ShouldHaveSingleItem(); spelunkTunnels.Opened.ShouldHaveSingleItem(); // The saved host, as saved — not the address the link happened to carry, and its own login, since // this one does not sign in through Spelunk. spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldBeNull(); var request = ssh.Requests.ShouldHaveSingleItem(); request.Host.ShouldBe("203.0.113.10"); request.Username.ShouldBe("deploy"); await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync(); } [Fact] public async Task ALinkForAHostSetUpByHand_AddsOneOfItsOwn() { // Untagged, so not one a link made: it is left as it is, and the link gets the host links make. var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.Hosts.Count.ShouldBe(2); vault.Hosts.Single(row => row.Label == "web-01").Host.TagIds.ShouldBeEmpty(); vault.Hosts.Single(row => row.Label == "web 01").Host.SignsInThroughSpelunk.ShouldBeTrue(); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ALinkWhileLocked_WaitsForTheUnlock() { await EnrolledAndConfirmedAsync(); shell.OpenLink(Link(host: "203.0.113.10")); shell.Vault.ShouldBeNull(); shell.StatusMessage.ShouldContain("Unlock to open web 01"); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); var vault = shell.Vault.ShouldNotBeNull(); vault.IsEditing.ShouldBeTrue(); vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString()); vault.EditorSpelunkSignIn.ShouldBeTrue(); } [Fact] public async Task ALink_LeavesAHalfEditedHostAlone() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.EditorLabel = "something I was typing"; shell.OpenLink(Link(host: "203.0.113.10")); vault.EditorLabel.ShouldBe("something I was typing"); vault.EditorThroughSpelunk.ShouldBeFalse(); vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel"); } // ---- Signing this device in to Spelunk ---- [Fact] public async Task ASignInOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway() { await UnlockedAsync(); var vault = shell.Vault!; await SignInToSpelunkAsync(vault, SpelunkApi + "/"); // Signed in through the browser, for the address in its one form, and kept on this device. spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]); var signedIn = vault.SpelunkSignIns.ShouldHaveSingleItem(); signedIn.ApiBase.ShouldBe(SpelunkApi); signedIn.Who.ShouldContain("ops@example.com"); vault.SpelunkSignInFormNote.ShouldContain("Signed in"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.IsEditing.ShouldBeFalse("signing in to this Spelunk is the say-so a link needs"); var added = vault.Hosts.ShouldHaveSingleItem(); added.Host.SignsInThroughSpelunk.ShouldBeTrue(); spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22)); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASignIn_IsNeverSentToAnotherSpelunk() { await UnlockedAsync(); var vault = shell.Vault!; await SignInToSpelunkAsync(vault, SpelunkApi); // A host on a Spelunk this device did not sign in to: the sign-in it has is for somewhere else. vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; vault.EditorHostname = "203.0.113.10"; vault.EditorThroughSpelunk = true; vault.EditorSpelunkSignIn = true; vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example"; vault.EditorSpelunkServerId = SpelunkServer.ToString(); vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); vault.Status.ShouldNotBeNull().ShouldContain("not signed in there"); } [Fact] public async Task AnAddressThatWouldSendTheSignInInClear_IsRefusedOnTheForm() { await UnlockedAsync(); var vault = shell.Vault!; await SignInToSpelunkAsync(vault, "http://api.spelunk.example.com"); spelunkAccounts.SignedIn.ShouldBeEmpty("no browser is opened for an address that would be refused"); vault.SpelunkSignIns.ShouldBeEmpty(); vault.SpelunkSignInFormNote.ShouldContain("https"); } [Fact] public async Task ASignInCancelledInTheBrowser_IsSaid_AndNothingIsKept() { await UnlockedAsync(); var vault = shell.Vault!; spelunkAccounts.Refusal = new SpelunkSignInException("Signing in to Spelunk was cancelled in the browser."); await SignInToSpelunkAsync(vault, SpelunkApi); vault.SpelunkSignIns.ShouldBeEmpty(); vault.SpelunkSignInFormNote.ShouldContain("cancelled in the browser"); vault.IsSigningInToSpelunk.ShouldBeFalse(); } [Fact] public async Task TheEditor_SaysWhoItReachesSpelunkAs_AndSignsInFromThere() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.EditorThroughSpelunk = true; vault.EditorSpelunkApi = SpelunkApi + "/"; vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null); spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]); vault.EditorSpelunkNeedsSignIn.ShouldBeFalse(); vault.SpelunkSignIns.ShouldHaveSingleItem(); } [Fact] public async Task SpelunkSigningThisDeviceOut_ForgetsTheSignIn_SoTheNextConnectAsksForANewOne() { spelunkTunnels.Refusal = new SpelunkTunnelException( "Spelunk signed this device out: your password changed.", HttpStatusCode.Unauthorized); var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldNotBeNull().ShouldContain("signed this device out"); vault.SpelunkSignIns.ShouldBeEmpty(); (await vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty(); // The next attempt does not repeat the refusal: it asks to sign in. spelunkTunnels.Refusal = null; await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.Count.ShouldBe(1); vault.Status.ShouldNotBeNull().ShouldContain("not signed in there"); } [Fact] public async Task AServerOrOrganisationSpelunkRefuses_KeepsTheSignIn() { // A 403 is about this server, not about who is signed in: every other server stays reachable. spelunkTunnels.Refusal = new SpelunkTunnelException( "You are not allowed to open a terminal on this server.", HttpStatusCode.Forbidden); var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldNotBeNull().ShouldContain("not allowed"); vault.SpelunkSignIns.ShouldHaveSingleItem(); } [Fact] public async Task ADodoSshAccountTheProviderRefuses_ReachesNoSpelunk_AndSignsThisDeviceOut() { var vault = await ReadyToConnectThroughSpelunkAsync(); server.SignInRefusal = new OidcException("The account is disabled.", "invalid_grant"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldBeEmpty("Spelunk is not reached for an account DodoSSH no longer has"); ssh.Requests.ShouldBeEmpty(); vault.Status.ShouldNotBeNull().ShouldContain("no longer signs in"); // Signed out of every Spelunk, there and here, and of DodoSSH: offline, and saying why. spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); vault.SpelunkSignIns.ShouldBeEmpty(); shell.IsOnline.ShouldBeFalse(); vault.OfflineReason.ShouldNotBeNull().ShouldContain("refused"); } [Fact] public async Task ADodoSshSignInThatCannotBeChecked_ReachesNoSpelunk_ButIsKept() { var vault = await ReadyToConnectThroughSpelunkAsync(); server.SignInRefusal = new HttpRequestException("The identity provider could not be reached."); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldBeEmpty(); vault.Status.ShouldNotBeNull().ShouldContain("could not check"); vault.SpelunkSignIns.ShouldHaveSingleItem("a provider that is down is not an account that is gone"); shell.IsOnline.ShouldBeTrue(); } [Fact] public async Task ADodoSshSignInRefusedOnResume_SignsThisDeviceOutOfEverySpelunkToo() { await ReadyToConnectThroughSpelunkAsync(); await shell.Vault!.SyncOnOpenAsync(Token); await shell.LockCommand.ExecuteAsync(null); spelunkAccounts.SignedOut.ShouldBeEmpty(); resumeFailure = new OidcException("The token endpoint returned 400: Account disabled.", "invalid_grant"); var relaunch = Relaunch(resume: ResumeAsync); await using var _ = relaunch.ConfigureAwait(false); await relaunch.StartAsync(Token); relaunch.Passphrase = Passphrase; await relaunch.UnlockCommand.ExecuteAsync(null); await relaunch.Vault.ShouldNotBeNull().SyncOnOpenAsync(Token); // The token Spelunk is told to end is the one the first launch kept: the sign-in outlived the lock and // the relaunch, and went only when DodoSSH's own did. relaunch.IsOnline.ShouldBeFalse(); spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); relaunch.Vault.SpelunkSignIns.ShouldBeEmpty(); (await relaunch.Vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty(); relaunch.Vault.Status.ShouldNotBeNull().ShouldContain("signed out of Spelunk"); } [Fact] public async Task SigningOutOfSpelunk_OnTheSecurityPage_EndsItThereAndHere() { var vault = await ReadyToConnectThroughSpelunkAsync(); await vault.SignOutOfSpelunkCommand.ExecuteAsync(vault.SpelunkSignIns.Single()); spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); vault.SpelunkSignIns.ShouldBeEmpty(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldBeEmpty(); } [Fact] public async Task SigningOutOfDodoSsh_SignsOutOfEverySpelunk() { await ReadyToConnectThroughSpelunkAsync(); shell.SignOutCommand.Execute(null); await shell.ConfirmSignOutCommand.ExecuteAsync(null); spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); } private static async Task SignInToSpelunkAsync(VaultViewModel vault, string apiBase) { vault.SpelunkSignInFormApi = apiBase; await vault.SignInToSpelunkCommand.ExecuteAsync(null); } /// A saved host through Spelunk, bound to a stored SSH password, on a Spelunk this device is signed in to. private async Task ReadyToConnectThroughSpelunkAsync() { await UnlockedAsync(); var vault = shell.Vault!; await SignInToSpelunkAsync(vault, SpelunkApi); await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy"); vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; vault.EditorHostname = "203.0.113.10"; vault.EditorUsername = "deploy"; vault.EditorThroughSpelunk = true; vault.EditorSpelunkApi = SpelunkApi + "/"; vault.EditorSpelunkServerId = SpelunkServer.ToString(); vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .Single(choice => choice.Label == "deploy password"); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.Single(row => row.Host.Spelunk is not null); // Saved in the one form, whatever was typed — which is what lets a link find it. vault.SelectedHost.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi); return vault; } /// A saved host through Spelunk that Spelunk signs in to: no username, no key, no password. private async Task ReadyToSignInThroughSpelunkAsync() { await UnlockedAsync(); var vault = shell.Vault!; await SignInToSpelunkAsync(vault, SpelunkApi); vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; vault.EditorHostname = "203.0.113.10"; vault.EditorThroughSpelunk = true; vault.EditorSpelunkSignIn = true; vault.EditorSpelunkApi = SpelunkApi; vault.EditorSpelunkServerId = SpelunkServer.ToString(); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status); vault.SelectedHost.Host.SignsInThroughSpelunk.ShouldBeTrue(); return vault; } private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) => new(SpelunkApi, server ?? SpelunkServer, "web 01", host); /// /// Speaks SOCKS5 to the bridge as SSH.NET would, then reads until the bridge closes the stream — which it /// does once the tunnel has closed, so its reason is recorded by the time this returns. /// private static async Task ThroughSocksToTheEndAsync(int port) { using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, port, Token); var stream = client.GetStream(); var name = Encoding.ASCII.GetBytes("203.0.113.10"); await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token); await stream.ReadExactlyAsync(new byte[2], Token); await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token); await stream.ReadExactlyAsync(new byte[10], Token); var rest = new byte[256]; while (await stream.ReadAsync(rest, Token) > 0) { } } /// /// Spelunk as far as the shell can see it: a route over a real bridge, or a refusal, as a test arranges. /// private sealed class FakeSpelunkTunnel : ISpelunkTunnel { internal List<(string ApiBase, Guid ServerId, string Token, int Port)> Opened { get; } = []; /// The sign-in key each tunnel was asked for, in order; null for a plain tunnel. internal List SignIns { get; } = []; /// The account a sign-in answer names. internal string User { get; set; } = "root"; internal SpelunkTunnelException? Refusal { get; set; } /// A close Spelunk sends as soon as the tunnel is open, or null to leave it open. internal (int Code, string Reason)? CloseWith { get; set; } public async Task OpenAsync( string apiBase, Guid serverId, string token, int port, SpelunkSignIn? signIn, CancellationToken cancellationToken) { Opened.Add((apiBase, serverId, token, port)); SignIns.Add(signIn); if (Refusal is { } refusal) { throw refusal; } var (client, far) = await PairAsync(cancellationToken); if (CloseWith is { } close) { await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken); } return new SpelunkTunnelRoute(RelayBridge.Start(client), port, signIn, signIn is null ? null : User); } private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken) { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var dialling = new TcpClient(); var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, cancellationToken); var accepted = await listener.AcceptTcpClientAsync(cancellationToken); await connecting; return ( WebSocket.CreateFromStream( dialling.GetStream(), new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero }), WebSocket.CreateFromStream( accepted.GetStream(), new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero })); } } /// Signing in to Spelunk as far as the shell can see it: no browser, a sign-in or a refusal. private sealed class FakeSpelunkAccounts : ISpelunkAccounts { /// The Spelunks a sign-in was asked for, in order. internal List SignedIn { get; } = []; /// The tokens that were signed out, in order. internal List SignedOut { get; } = []; internal SpelunkSignInException? Refusal { get; set; } public Task SignInAsync(string apiBase, CancellationToken cancellationToken) { SignedIn.Add(apiBase); return Refusal is { } refusal ? Task.FromException(refusal) : Task.FromResult(new SpelunkSignInRecord( apiBase, SpelunkToken, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.UtcNow)); } public Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken) { SignedOut.Add(signIn.Token); return Task.CompletedTask; } } }