using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Session;
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 and the link promise: a Spelunk host is never dialled directly and
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, and a link
/// never connects anything the user has not saved.
///
///
public sealed partial class ShellFlowTests
{
private const string SpelunkApi = "https://api.spelunk.example.com";
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
private readonly FakeSpelunkTunnel spelunkTunnels = 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);
// The key from the credential, to the Spelunk and server the host names, for the host's own port.
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
// 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 ASpelunkHostWithNoKeyChosen_IsNotSaved()
{
await UnlockedAsync();
var vault = shell.Vault!;
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();
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
vault.Hosts.ShouldBeEmpty();
}
[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.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
}
[Fact]
public async Task ACredentialHoldingASpelunkKey_IsInUse_WhereverThatIsDrawn()
{
// So deleting it is warned about like any other credential a host needs, rather than looking unused.
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.ShowSectionCommand.Execute(VaultSection.All);
var row = vault.VaultItems.Single(item => string.Equals(item.Name, "spelunk key", StringComparison.Ordinal));
row.UsedBySummary.ShouldBe("web-01");
vault.SelectedVaultItem = row;
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
}
// ---- 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.EditorSpelunkApi.ShouldBe(SpelunkApi);
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
// for it would be offering to send the key wherever a link says.
vault.EditorSelectedSpelunkKey.ShouldBeNull();
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 ALinkForASecondServerOnAKnownSpelunk_PicksTheKeyThatSpelunkAlreadyGets()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
shell.OpenLink(Link(server: Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c")));
vault.IsEditing.ShouldBeTrue();
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
}
[Fact]
public async Task ALinkForASavedHost_ConnectsToIt()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
vault.IsEditing.ShouldBeFalse();
spelunkTunnels.Opened.ShouldHaveSingleItem();
// The saved host, as saved — not the address the link happened to carry.
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("203.0.113.10");
await request.Proxy.ShouldNotBeNull().Lifetime.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());
}
[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");
}
/// A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.
private async Task ReadyToConnectThroughSpelunkAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
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.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
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;
}
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 ApiKey, int Port)> Opened { get; } = [];
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 apiKey,
int port,
CancellationToken cancellationToken)
{
Opened.Add((apiBase, serverId, apiKey, port));
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);
}
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 }));
}
}
}