Public Access
Connections: drop the manual box's view-model state now that both heads ask through the prompt
The phone's Connections screen no longer draws the user@host + password box, and the desktop never did, so nothing bound VaultViewModel.ManualTarget, ManualPassword, ManualStatus or ConnectManuallyCommand any more, nor MainWindowViewModel.ConnectToRecentCommand, whose address branch wrote into that box. They are gone, and so is VaultViewModel.AskAboutHost, whose one caller was ConnectToRecent. TryParseManualTarget, ManualEndpoint and ConnectToEndpointAsync stay: the prompt, the desktop's Ctrl+K palette and the phone's hosts search all use them. The reasoning the removed members carried moved onto ConnectToEndpointAsync and ChoosePromptRecentAsync. The tests whose subject is still live now go through the prompt instead of the box: a typed address dialled exactly as typed, port 22 by default, the refusal sentences, the host-key retry replaying the attempt that raised it, the host-key card staying over the surface it was typed on, the session facts for a typed password, and no forwards for a typed address. The recent-row cases now drive ChoosePromptRecentCommand. Two cases existing prompt tests already covered were deleted. Manual check 11.8 now describes recent rows landing on the prompt.
This commit is contained in:
1 parent
0cecf1afc4
commit
fa85ad1eff
7 files changed
+142
-313
No files matched your search
@@ -152,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();
|
||||
|
||||
@@ -1040,19 +1040,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");
|
||||
@@ -1065,71 +1063,55 @@ 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "root@box";
|
||||
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.ManualStatus.ShouldContain("password");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
@@ -1188,8 +1170,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
|
||||
/// address stays where it was typed, so filling the password in is all that is left to do.
|
||||
/// 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()
|
||||
@@ -1429,8 +1411,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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()
|
||||
@@ -1464,6 +1449,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
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>
|
||||
@@ -1485,7 +1472,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
/// <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.
|
||||
@@ -1502,10 +1489,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();
|
||||
|
||||
@@ -1520,46 +1504,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>
|
||||
@@ -1567,17 +1530,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>
|
||||
@@ -3599,8 +3563,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()
|
||||
@@ -3609,19 +3573,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();
|
||||
}
|
||||
|
||||
@@ -8592,14 +8551,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");
|
||||
@@ -11399,6 +11355,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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user