Public Access
Terminal: ask what to connect to when Terminal is pressed with nothing open
The terminal surface with no session used to be an empty frame, with the tab row's "+" the only thing on it that did anything. It now holds a prompt, in the order ssh asks: "connect to:" takes a saved host's name, matched exactly, with the hosts that match so far listed under it for Tab or a click, or user@host[:port] for a machine that is not saved. A password line follows only when one is needed, worded as ssh words it. Escape steps back a line; recent connections are listed below. It is drawn by Avalonia in the WebView's rectangle, which the occlusion rule allows because the two are exclusive: the prompt shows only while there are no tabs, the WebView only for a tab with a session. Saved hosts connect through a new VaultViewModel.ConnectToHostAsync, which the drawer's own connect now goes through too, so every refusal is still made once. Each attempt now carries whether it may act on the drawer's remember-password tick, and the prompt's may not: otherwise a tick left on in a closed drawer would save a password typed at a prompt that never offered to keep anything. Covered by nine ShellFlowTests cases and four ConnectPromptTests; manual check 7.10b.
This commit is contained in:
1 parent
5b2e3f3651
commit
3c8c0980c2
9 files changed
+1154
-33
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1223,6 +1223,228 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
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>
|
||||
/// 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, for the reason the manual box gives: the
|
||||
/// server's "authentication failed" would be a sentence about the machine when the problem is the box.
|
||||
/// </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:");
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
||||
Reference in new issue
Block a user