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;
}
}
}