Public Access
Merge origin/main into claude/spelunk-api-key-tenant-6373cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6f6e92f315
25 files changed
+2667
-417
No files matched your search
@@ -0,0 +1,222 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// How the empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What each key decides is <c>ShellFlowTests</c>' business; this is the half only a real control can show —
|
||||
/// that the caret is on the line that is asking, every time the line that is asking changes. A prompt whose
|
||||
/// password line appeared without the caret on it would take the password into the name it had just read.
|
||||
/// The fixture is the palette suite's, for the palette suite's reasons: a real shell over a real unlocked
|
||||
/// vault, and nothing that reaches a network.
|
||||
/// </remarks>
|
||||
public sealed class ConnectPromptTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"prompt-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
await SeedAsync();
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
ClientPaths.Default,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
// Never asked for a session: the palette searches the host list and connects through the
|
||||
// vault's own command, and nothing on this screen transfers a file.
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
||||
// path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
|
||||
State = ShellState.Unlocked,
|
||||
Vault = vault,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePromptTakesTheKeyboardWhenItAppears()
|
||||
{
|
||||
await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter on an address moves the question to the password, and the caret with it — typing straight on is
|
||||
/// what somebody does next, and it has to land in the password box rather than on the end of the address.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterOnAnAddressPutsTheCaretOnThePassword()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||
prompt.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>Escape off the password line puts the caret back on the name, which is still there.</remarks>
|
||||
[Fact]
|
||||
public async Task EscapeFromThePasswordPutsTheCaretBackOnTheName()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "root@10.0.0.5";
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||
prompt.TargetBox.IsFocused.ShouldBeTrue();
|
||||
shell.PromptText.ShouldBe("root@10.0.0.5");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Tab completes rather than moving the focus away, when there is something to complete to — and leaves
|
||||
/// the caret at the end of what it wrote, so Enter is the next key rather than End.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TabCompletesTheNameAndKeepsTheCaretOnIt()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "host-3";
|
||||
shell.PromptMatches.ShouldNotBeEmpty();
|
||||
|
||||
shell.PromptText = "ost-3";
|
||||
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||
|
||||
shell.PromptText.ShouldBe("host-3");
|
||||
prompt.TargetBox.IsFocused.ShouldBeTrue("Tab completed rather than moving on");
|
||||
prompt.TargetBox.CaretIndex.ShouldBe("host-3".Length);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it.</summary>
|
||||
private Task OnThePromptAsync(Action<ConnectPrompt, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.IsConnectPromptShowing.ShouldBeTrue("every case here starts on an empty terminal");
|
||||
|
||||
var prompt = new ConnectPrompt { DataContext = shell };
|
||||
var window = new Window { Content = prompt };
|
||||
LayoutHarness.Settle(window, 900, 600);
|
||||
|
||||
// The posted focus the application's dispatcher would run once the prompt was laid out.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
try
|
||||
{
|
||||
body(prompt, window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Runs the layout pass, and the focus it posts, that the application's dispatcher would.</summary>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
/// <remarks>Enough hosts that a partial name has more than one to match.</remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = $"host-{i}";
|
||||
vault.EditorHostname = $"host-{i}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- A machine that is not saved ----
|
||||
|
||||
/// <remarks>
|
||||
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
|
||||
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@10.0.0.5:2222";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeTrue();
|
||||
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
|
||||
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
|
||||
|
||||
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
|
||||
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
|
||||
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
|
||||
/// makes the row comparable with a saved host's address, which always carries one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
|
||||
{
|
||||
await OnThePaletteAsync((_, _) =>
|
||||
{
|
||||
shell.SearchText = "root@box";
|
||||
|
||||
shell.AdHocAddress.ShouldBe("root@box:22");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
|
||||
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
|
||||
/// machine that was never saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "nothing-is-called-this";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeFalse();
|
||||
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
|
||||
VisibleTexts(palette).ShouldContain(
|
||||
text => text.Contains("user@host", StringComparison.Ordinal),
|
||||
"the hint names the other thing the box takes");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
|
||||
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressThatCannotBeReadSaysWhy()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@box:99999";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeFalse();
|
||||
VisibleTexts(palette).ShouldContain(
|
||||
text => text.Contains("port", StringComparison.Ordinal),
|
||||
"the refusal is the manual parser's own sentence");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
|
||||
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
|
||||
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
|
||||
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
|
||||
/// to keep.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EscapeFromThePasswordGoesBackToTheAddress()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
shell.AdHocPassword = "hunter2";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
|
||||
shell.IsAskingForAdHocPassword.ShouldBeFalse();
|
||||
shell.SearchText.ShouldBe("root@10.0.0.5");
|
||||
shell.AdHocPassword.ShouldBeEmpty();
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
|
||||
/// password with an empty one answers "authentication failed", which is a sentence about the machine
|
||||
/// when the problem is the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
|
||||
{
|
||||
var started = new List<ConnectionAttemptEventArgs>();
|
||||
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
shell.HasAdHocRefusal.ShouldBeTrue();
|
||||
started.ShouldBeEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
|
||||
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
|
||||
/// handling; what the palette promises is the attempt and the closing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
|
||||
{
|
||||
var started = new List<ConnectionAttemptEventArgs>();
|
||||
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||
var hostsBefore = vault.Hosts.Count;
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5:2222";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
shell.AdHocPassword = "hunter2";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
|
||||
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
|
||||
|
||||
var attempt = started.ShouldHaveSingleItem();
|
||||
attempt.Address.ShouldBe("root@10.0.0.5:2222");
|
||||
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
|
||||
});
|
||||
|
||||
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
|
||||
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
|
||||
/// it was set up to be reached. The typed row is still offered, one arrow away.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressASavedHostDialsHighlightsThatHost()
|
||||
{
|
||||
await OnThePaletteAsync((_, _) =>
|
||||
{
|
||||
shell.SearchText = "deploy@host-3.internal";
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeTrue();
|
||||
shell.IsAdHocSelected.ShouldBeFalse();
|
||||
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
|
||||
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
|
||||
// Every seeded host's address starts with this, and none of them is exactly it.
|
||||
shell.SearchText = "deploy@host";
|
||||
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
|
||||
shell.IsAdHocSelected.ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeFalse();
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeTrue();
|
||||
shell.SelectedSearchResult.ShouldBeNull();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
|
||||
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
|
||||
[Fact]
|
||||
public async Task ClickingTheAddressAsksForItsPassword()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
Relayout(window);
|
||||
|
||||
var row = palette.AdHocTargetRow;
|
||||
window.MouseDown(Centre(row, window), MouseButton.Left);
|
||||
window.MouseUp(Centre(row, window), MouseButton.Left);
|
||||
|
||||
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
||||
|
||||
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
|
||||
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
|
||||
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
|
||||
await EventuallyAsync(
|
||||
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
|
||||
"the session's end should have reached its forwards");
|
||||
|
||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||
@@ -145,13 +152,11 @@ public sealed partial class ShellFlowTests
|
||||
[Fact]
|
||||
public async Task ATypedInAddress_StartsNoForwards()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "deploy@10.0.0.9";
|
||||
vault.ManualPassword = "hunter2";
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync("deploy@10.0.0.9", "hunter2");
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
||||
ssh.Forwards.ShouldBeEmpty();
|
||||
|
||||
@@ -1041,19 +1041,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// The one connection this application makes to a machine that is not in the keychain. What is worth
|
||||
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
|
||||
/// the port all come out of the one box.
|
||||
/// the port all come out of the one line, and the spaces a paste brings with it are not part of any of
|
||||
/// them.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
|
||||
public async Task ThePrompt_DialsATypedAddressExactlyAsItWasTyped()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = " deploy@build.internal:2222 ";
|
||||
vault.ManualPassword = "hunter2";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync(" deploy@build.internal:2222 ", "hunter2");
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("build.internal");
|
||||
@@ -1066,74 +1064,416 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
|
||||
public async Task ThePrompt_DialsAnAddressWithNoPortOnTwentyTwo()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "root@box";
|
||||
vault.ManualPassword = "hunter2";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync("root@box", "hunter2");
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
|
||||
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
|
||||
/// rather than here as a sentence about the box that was typed into.
|
||||
/// rather than here as a sentence about the line that was typed into.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first two have no <c>@</c>, so the prompt reads them as the name of a saved host rather than as an
|
||||
/// address — which is why their sentences are the prompt's own and not the address parser's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("", "Type a machine")]
|
||||
[InlineData("build.internal", "Say who to log in as")]
|
||||
[InlineData("", "Type the name of a saved host")]
|
||||
[InlineData("build.internal", "No saved host is called")]
|
||||
[InlineData("deploy@", "Say who to log in as")]
|
||||
[InlineData("@build.internal", "Say who to log in as")]
|
||||
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
|
||||
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
|
||||
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
|
||||
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
|
||||
public async Task ThePrompt_RefusesWhatCannotBeRead_BeforeAnythingIsDialled(
|
||||
string typed,
|
||||
string because)
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = typed;
|
||||
vault.ManualPassword = "hunter2";
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = typed;
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
vault.ManualStatus.ShouldContain(because);
|
||||
shell.PromptRefusal.ShouldContain(because);
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse("a line that cannot be read has no password to ask for");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
|
||||
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
|
||||
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
|
||||
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
|
||||
/// and for nothing else. A plain search must not grow one on every keystroke.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("root@box", "root@box:22")]
|
||||
[InlineData(" deploy@build.internal:2222 ", "deploy@build.internal:2222")]
|
||||
public async Task AnAddressTypedIntoTheHostsSearch_IsOfferedAsAMachineToConnectTo(
|
||||
string typed,
|
||||
string offered)
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
vault.HasFilterAddress.ShouldBeFalse("a search without an @ is a search");
|
||||
vault.HasFilterAddressRefusal.ShouldBeFalse();
|
||||
|
||||
vault.HostFilter = typed;
|
||||
|
||||
vault.HasFilterAddress.ShouldBeTrue();
|
||||
vault.FilterAddressText.ShouldBe(offered);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path from the search box: the address and the password typed there are what is dialled, a tab
|
||||
/// opens for it, and the keychain is exactly as it was — that is the point of connecting this way. The box
|
||||
/// and the password are both empty afterwards, so the list is not left filtered to a machine it does not
|
||||
/// hold and the password does not wait in the box for the next one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
|
||||
public async Task TheHostsSearchConnectsToATypedAddress_AndSavesNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "root@box";
|
||||
var hostsBefore = vault.Hosts.Count;
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
vault.HostFilter = "deploy@build.internal:2222";
|
||||
vault.FilterAddressPassword = "hunter2";
|
||||
|
||||
vault.ManualStatus.ShouldContain("password");
|
||||
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("build.internal");
|
||||
request.Port.ShouldBe(2222);
|
||||
request.Username.ShouldBe("deploy");
|
||||
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
|
||||
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||
|
||||
vault.HostFilter.ShouldBeEmpty();
|
||||
vault.FilterAddressPassword.ShouldBeEmpty();
|
||||
vault.HasFilterAddress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty password is refused under the box rather than sent, as the connect prompt refuses one — and
|
||||
/// the address stays where it was typed, so filling the password in is all that is left to do.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.HostFilter = "root@box";
|
||||
|
||||
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
|
||||
|
||||
vault.FilterAddressRefusal.ShouldContain("password");
|
||||
vault.HostFilter.ShouldBe("root@box");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
shell.Tabs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A search that looks like an address and cannot be one says why, in the manual parser's own words,
|
||||
/// rather than offering nothing and leaving the box looking ignored.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressInTheHostsSearchThatCannotBeRead_SaysWhy()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.HostFilter = "root@box:70000";
|
||||
|
||||
vault.HasFilterAddress.ShouldBeFalse();
|
||||
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
|
||||
}
|
||||
|
||||
// ---- The empty terminal's prompt ----
|
||||
|
||||
/// <remarks>
|
||||
/// Pressing Terminal with nothing open shows the prompt, and a tab opening takes it away: the two share the
|
||||
/// renderer's rectangle, and the prompt is only allowed in it because they are exclusive.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_ShowsOnTheTerminalWithNothingOpen_AndGoesWhenATabOpens()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.IsConnectPromptShowing.ShouldBeFalse("the hosts screen is showing, not the terminal");
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.IsConnectPromptShowing.ShouldBeTrue();
|
||||
|
||||
shell.PromptText = "deploy@build.internal";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
shell.PromptPassword = "hunter2";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.HasTabs.ShouldBeTrue();
|
||||
shell.IsConnectPromptShowing.ShouldBeFalse("the session's tab has the rectangle now");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A saved host by name — case aside, as a person types it — whose binding wants a typed password: the
|
||||
/// prompt asks for it in ssh's own words and dials the host with it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_ConnectsToASavedHostByName_AskingForItsPasswordFirst()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "PROD-DB";
|
||||
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldBeEmpty("nothing is dialled before the password is typed");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
|
||||
|
||||
shell.PromptPassword = "s3cret";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem().Credential
|
||||
.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
||||
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
shell.PromptText.ShouldBeEmpty();
|
||||
shell.PromptPassword.ShouldBeEmpty("the password does not wait in the box");
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <b>The failure the prompt could most easily cause.</b> The drawer's remember-this tick is read when a
|
||||
/// connection succeeds, and the prompt goes through the same connect path — so without the attempt
|
||||
/// carrying its own answer, a tick left on in a drawer that is not even open would save a password typed
|
||||
/// at a prompt that never offered to save anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod-db";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
shell.PromptPassword = "s3cret";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem();
|
||||
shell.HasTabs.ShouldBeTrue("the connection succeeded, which is when remembering would happen");
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An address the keychain does not hold is dialled as typed, with the typed password, and the keychain
|
||||
/// is left exactly as it was.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_ConnectsToATypedAddress_AndSavesNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "deploy@build.internal:2222";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||
|
||||
shell.PromptPassword = "hunter2";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("build.internal");
|
||||
request.Port.ShouldBe(2222);
|
||||
request.Username.ShouldBe("deploy");
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem("connecting without saving saves nothing");
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter on a name is a decision rather than a search, so a partial name is refused — but the refusal
|
||||
/// names the nearest host, and Tab takes it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_RefusesAPartialName_AndTabCompletesIt()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod";
|
||||
|
||||
shell.PromptMatches.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptRefusal.ShouldContain("Tab");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
|
||||
shell.CompletePrompt().ShouldBeTrue();
|
||||
shell.PromptText.ShouldBe("prod-db");
|
||||
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A tap on a match takes that host outright — the phone's way of completing a name, where there is no
|
||||
/// Tab — and carries on as Enter would: here, to the host's password, with nothing dialled yet.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATappedMatchOnThePrompt_TakesThatHost()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod";
|
||||
|
||||
await shell.ChoosePromptMatchCommand.ExecuteAsync(shell.PromptMatches.ShouldHaveSingleItem());
|
||||
|
||||
shell.PromptText.ShouldBe("prod-db");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
shell.ShowsPromptMatches.ShouldBeFalse("the matches are for the name, not drawn under its password");
|
||||
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
|
||||
/// it refuses rather than guessing and points at the palette, which does say.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_RefusesANameTwoHostsShare()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod-db";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptRefusal.ShouldContain("More than one");
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Escape walks back one line at a time, as it does in the palette: off the password onto the name with
|
||||
/// the name intact, and then the name cleared.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EscapeAtThePrompt_StepsBackALineAtATime()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "root@box";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
shell.PromptPassword = "hunter2";
|
||||
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
shell.PromptText.ShouldBe("root@box");
|
||||
shell.PromptPassword.ShouldBeEmpty();
|
||||
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
|
||||
shell.PromptText.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty password is refused on the prompt rather than sent: the server's "authentication failed" would
|
||||
/// be a sentence about the machine when the problem is the box. A password is the only thing a typed
|
||||
/// address can authenticate with — offering the keychain's keys would be a second binding resolution
|
||||
/// beside the connect path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only
|
||||
/// copy of.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_RefusesAnEmptyPassword()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "root@box";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptRefusal.ShouldContain("password");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A recent connection that names no keychain host is the address that was dialled, so choosing it asks
|
||||
/// for that address's password — the log never held the password, which is the point of the path.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARecentAddressOnThePrompt_AsksForItsPassword()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
|
||||
await shell.ChoosePromptRecentCommand.ExecuteAsync(
|
||||
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
||||
|
||||
shell.PromptText.ShouldBe("deploy@build.internal:2222");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||
shell.PromptPassword.ShouldBeEmpty("the log never held one");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
|
||||
/// away, or the next machine typed would be dialled with somebody else's password.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ChangingTheAddressInTheHostsSearch_DropsThePasswordTypedForTheLastOne()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.HostFilter = "root@box";
|
||||
vault.FilterAddressPassword = "hunter2";
|
||||
|
||||
vault.HostFilter = "root@other-box";
|
||||
|
||||
vault.FilterAddressPassword.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
||||
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
|
||||
/// the only way to connect; with a typed address it would answer "do you trust this key" by dialling a
|
||||
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
|
||||
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
|
||||
/// the assertion would still catch it.
|
||||
@@ -1150,10 +1490,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
ssh.Failure = new SshHostKeyUnknownException(
|
||||
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
||||
|
||||
vault.ManualTarget = "deploy@build.internal:2222";
|
||||
vault.ManualPassword = "hunter2";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
|
||||
@@ -1168,46 +1505,25 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
|
||||
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
|
||||
/// is where its key, its password box and its refusals already live.
|
||||
/// A recent row is one of two different things, and choosing it has to lead to whichever one it is. The
|
||||
/// keychain half takes the host as typing its name would, so its key, its saved password and its
|
||||
/// refusals are the keychain's — here, a host with nothing saved, so the prompt asks for its password
|
||||
/// with nothing dialled yet.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
|
||||
public async Task ARecentHostOnThePrompt_TakesThatHost()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
var host = vault.Hosts[0];
|
||||
vault.SelectedHost = null;
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
|
||||
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
||||
|
||||
shell.IsHostsShowing.ShouldBeTrue();
|
||||
vault.SelectedHost.ShouldBe(host);
|
||||
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
|
||||
/// so it goes straight back in — without the password, which was never stored and whose absence is the
|
||||
/// point of that path rather than a gap in it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
|
||||
// screen the application happens to open on.
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
|
||||
shell.ConnectToRecentCommand.Execute(
|
||||
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
||||
await shell.ChoosePromptRecentCommand.ExecuteAsync(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
||||
|
||||
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
|
||||
vault.ManualPassword.ShouldBeEmpty();
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
|
||||
shell.PromptText.ShouldBe("prod-db", "a keychain host is taken by name, not as the address the log kept");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
shell.PromptPasswordLabel.ShouldBe($"{host.Address}'s password:");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1215,17 +1531,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
/// about it, so the address is the honest answer rather than a tap that does nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
|
||||
public async Task ARecentHostThatHasGone_FallsBackToItsAddressOnThePrompt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
|
||||
shell.ConnectToRecentCommand.Execute(
|
||||
await shell.ChoosePromptRecentCommand.ExecuteAsync(
|
||||
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
|
||||
|
||||
vault.ManualTarget.ShouldBe("root@prod-db:22");
|
||||
shell.IsTerminalSurface.ShouldBeTrue();
|
||||
shell.PromptText.ShouldBe("root@prod-db:22");
|
||||
shell.PromptPasswordLabel.ShouldBe("root@prod-db:22's password:");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>One row of the connection log, built by hand.</summary>
|
||||
@@ -3247,8 +3564,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
|
||||
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
|
||||
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
|
||||
/// and what the user comes back to whichever way they answer.
|
||||
/// connect prompt is on the terminal surface, so staying there is what keeps it behind the sheet — and
|
||||
/// what the user comes back to whichever way they answer.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
||||
@@ -3257,19 +3574,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
|
||||
ssh.Failure = new SshHostKeyUnknownException(
|
||||
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
||||
|
||||
vault.ManualTarget = "deploy@build.internal:2222";
|
||||
vault.ManualPassword = "hunter2";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the connect prompt is on this surface and has not been taken away");
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
@@ -8240,14 +8552,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "deploy@build.internal:2222";
|
||||
vault.ManualPassword = "hunter2";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||
|
||||
shell.IsSessionConnected.ShouldBeTrue();
|
||||
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||
@@ -11047,6 +11356,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
return vault;
|
||||
}
|
||||
|
||||
/// <summary>Types an address at the connect prompt, then its password, pressing Enter after each.</summary>
|
||||
private async Task ConnectAtThePromptAsync(string typed, string password)
|
||||
{
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
|
||||
shell.PromptText = typed;
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptPassword = password;
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
||||
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
||||
{
|
||||
|
||||
@@ -211,6 +211,31 @@ public sealed class SftpSessionTests(SshServerFixture fixture)
|
||||
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostWhoseLoginPrintsText_FailsWithinTheConnectTimeout_AndSaysWhy()
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var request = Request() with { Port = fixture.ChattyPort, ConnectTimeout = TimeSpan.FromSeconds(3) };
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.OpenSftpAsync(request, Token));
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
// The banner's first four bytes read as the length of a packet about a gigabyte long, and SSH.NET's
|
||||
// own default is to wait for the rest of it for ever — which the file browser drew as "Connecting…"
|
||||
// with no end. The outer bound is only there so that regression fails this test rather than hangs it,
|
||||
// and it is a cancellation rather than a timeout so that it cannot be mistaken for the answer.
|
||||
using var bound = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||
bound.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
var failure = await Should.ThrowAsync<TimeoutException>(async () =>
|
||||
await factory.OpenSftpAsync(request, Token).WaitAsync(bound.Token));
|
||||
|
||||
failure.Message.ShouldContain("SFTP service did not answer within 3 seconds");
|
||||
}
|
||||
|
||||
private SshConnectionRequest Request() => new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
|
||||
@@ -42,6 +42,17 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
|
||||
private const int SshPort = 2222;
|
||||
|
||||
/// <summary>
|
||||
/// A second port on the same sshd, where every session starts with a line of text before SFTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What a host with an <c>echo</c> in its login script looks like to a file-transfer client: the text
|
||||
/// arrives where the server's first SFTP packet should be. A port rather than an account because this
|
||||
/// image's sshd runs unprivileged and cannot log in as anybody but <see cref="Username"/>, and a port
|
||||
/// rather than a change to the main one so the shell and SFTP suites keep a server that behaves.
|
||||
/// </remarks>
|
||||
private const int ChattySshPort = 2223;
|
||||
|
||||
/// <summary>
|
||||
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
||||
/// </summary>
|
||||
@@ -65,6 +76,9 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// <summary>Host port the container's sshd is published on.</summary>
|
||||
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
||||
|
||||
/// <summary>Host port of the same sshd whose sessions print a banner first; see <see cref="ChattySshPort"/>.</summary>
|
||||
public ushort ChattyPort => container!.GetMappedPublicPort(ChattySshPort);
|
||||
|
||||
/// <summary>Host the container is reachable at.</summary>
|
||||
public string Host => container!.Hostname;
|
||||
|
||||
@@ -83,6 +97,7 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
.WithEnvironment("SUDO_ACCESS", "false")
|
||||
.WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
|
||||
.WithPortBinding(SshPort, assignRandomHostPort: true)
|
||||
.WithPortBinding(ChattySshPort, assignRandomHostPort: true)
|
||||
|
||||
// The entrypoint generates host keys, rewrites sshd_config and installs the authorized
|
||||
// key before sshd is usable, so a published port is not readiness. Both conditions are
|
||||
@@ -176,6 +191,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
||||
/// of this fixture did exactly that and failed twenty-eight tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="ChattySshPort"/> block goes last because a <c>Match</c> runs to the end of the file:
|
||||
/// anything appended after it would apply only to that port.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ReconfigureAsync()
|
||||
{
|
||||
@@ -185,6 +204,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
||||
+ $" && sed -i 's/^Port {SshPort}$/Port {SshPort}\\nPort {ChattySshPort}/' /config/sshd/sshd_config"
|
||||
+ $" && printf '\\nMatch LocalPort {ChattySshPort}\\n"
|
||||
+ " ForceCommand /bin/sh -c \"echo Welcome to the test server; exec /usr/lib/ssh/sftp-server\"\\n'"
|
||||
+ " >> /config/sshd/sshd_config"
|
||||
+ " && pkill -HUP sshd",
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user