Files
DodoSSH/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs
T
laurensandClaude Opus 5.5 abd7e56bb5 Spelunk sign-in: end it with a refused DodoSSH resume, keep the token the check rotated, and send the token by the sign-in's own address
A DodoSSH resume the provider refuses now signs the device out of every Spelunk as well, as the check
before a Spelunk connection already did, so the README's "an account the provider no longer signs in is
signed out of DodoSSH and of every Spelunk" holds whichever finds it first. The forced refresh that check
makes is written down at once rather than at the next sync pass, so a crash in between cannot cost a
browser sign-in on the next launch. The tunnel is opened at the sign-in's own address rather than the
route's: the same string, but the token going where it came from is now said where it is sent.

Relaunches in the shell tests use the scripted Spelunk sign-in too, so none can open a browser or send a
sign-out anywhere real. Fixes a doc comment the account-refused handler had split, a README line still
pointing at the key picker, and the Android composition's comment on which redirect Spelunk allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:41:11 +02:00

917 lines
37 KiB
C#

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;
/// <summary>
/// A host reached through Spelunk, from the editor and from a link to the request that reaches SSH.
/// </summary>
/// <remarks>
/// <para>
/// The tunnel is stood in for at <see cref="ISpelunkTunnel"/>, and what it hands back is a real
/// <see cref="SpelunkTunnelRoute"/> 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 <c>SpelunkTunnelTests</c>' job.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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<RelayBridge>();
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<RelayBridge>();
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<RelayBridge>();
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<RelayBridge>();
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);
}
/// <summary>A saved host through Spelunk, bound to a stored SSH password, on a Spelunk this device is signed in to.</summary>
private async Task<VaultViewModel> 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;
}
/// <summary>A saved host through Spelunk that Spelunk signs in to: no username, no key, no password.</summary>
private async Task<VaultViewModel> 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);
/// <summary>
/// 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.
/// </summary>
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)
{
}
}
/// <summary>
/// Spelunk as far as the shell can see it: a route over a real bridge, or a refusal, as a test arranges.
/// </summary>
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
{
internal List<(string ApiBase, Guid ServerId, string Token, int Port)> Opened { get; } = [];
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
internal List<SpelunkSignIn?> SignIns { get; } = [];
/// <summary>The account a sign-in answer names.</summary>
internal string User { get; set; } = "root";
internal SpelunkTunnelException? Refusal { get; set; }
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
internal (int Code, string Reason)? CloseWith { get; set; }
public async Task<SpelunkTunnelRoute> 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 }));
}
}
/// <summary>Signing in to Spelunk as far as the shell can see it: no browser, a sign-in or a refusal.</summary>
private sealed class FakeSpelunkAccounts : ISpelunkAccounts
{
/// <summary>The Spelunks a sign-in was asked for, in order.</summary>
internal List<string> SignedIn { get; } = [];
/// <summary>The tokens that were signed out, in order.</summary>
internal List<string> SignedOut { get; } = [];
internal SpelunkSignInException? Refusal { get; set; }
public Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken)
{
SignedIn.Add(apiBase);
return Refusal is { } refusal
? Task.FromException<SpelunkSignInRecord>(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;
}
}
}