diff --git a/docs/manual-checks.md b/docs/manual-checks.md index e7f8f60..61a3d7e 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -2099,14 +2099,16 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`. With at least one keychain host and one machine connected to by hand in the log, open Connections with nothing running. -**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready -for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and -leaves the password box **empty**. Neither one dials anything on its own. +**Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that +name would — its password line, or straight into a terminal for a host with a key or a saved password. +Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password +line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is +given. -**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should -not have. If the password came back, something is storing it — it never was. And if the keychain one arrives -at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through -`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press. +**Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is +carrying a host id it should not have. If the password came back, something is storing it — it never was. +And if the keychain one shows the logged address rather than the host's name, the host id is not being +matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`. ### 11.9 The keychain and snippets are the vault's, and back walks out of them diff --git a/src/DodoSSH.Client.App/Views/QuickConnect.axaml b/src/DodoSSH.Client.App/Views/QuickConnect.axaml index 8ce6e00..7e25c96 100644 --- a/src/DodoSSH.Client.App/Views/QuickConnect.axaml +++ b/src/DodoSSH.Client.App/Views/QuickConnect.axaml @@ -170,7 +170,7 @@ The second step, in the same card, so the keyboard never leaves the palette: Enter on the address comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword, - and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in. + and VaultViewModel.ConnectToEndpointAsync for why a typed password is the only way in. --> diff --git a/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs index db1c0e9..0d49af2 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs @@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem e /// The keychain host this was, if it was one. /// /// Null for a connection made to a typed address, and that is a real distinction rather than missing - /// data — see VaultViewModel.ConnectManuallyAsync. It is what lets the Connections screen offer - /// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own - /// authentication behind it, and an address has only the box it was typed into. + /// data — see VaultViewModel.ConnectToEndpointAsync. It is what lets the connect prompt offer the + /// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and + /// an address has only the password typed for it — see MainWindowViewModel.ChoosePromptRecentAsync. /// internal Guid? HostId => entry.Secret.HostId; diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index ea231da..0a7ff5c 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -1857,9 +1857,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// button needs a command. /// /// - /// Not gated on there being a tab. With nothing open the surface is where a connection is made — the - /// phone's terminal screen draws its manual box there, and the desktop its prompt; see - /// — which is worth being able to reach deliberately. + /// Not gated on there being a tab. With nothing open the surface is where a connection is made — both + /// heads draw the connect prompt there; see — which is worth being + /// able to reach deliberately. /// /// [RelayCommand] @@ -1946,57 +1946,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp OnPropertyChanged(nameof(HasRecentConnections)); } - /// Goes back to a machine that has been connected to before. - /// - /// - /// Two destinations, because a recent row is one of two different things. One that names a - /// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one - /// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever - /// authentication the keychain resolves for it. Connecting from here instead would be a third connect - /// path that had to answer all of that again. - /// - /// - /// ◆ It raises that rather than merely selecting the row, and on the phone it has to. Nothing on - /// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the - /// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back - /// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See - /// VaultViewModel.AskAboutHost, which is where the two heads' answers are raised together. - /// - /// - /// One that names no item was typed into the manual box, and the log stored exactly what was dialled — - /// user@host:port, which is the grammar that box takes. So it goes back into the box, and what - /// is deliberately not restored is the password: it was never stored, which is the whole point of the - /// manual path, and a field that filled itself in would be claiming otherwise. - /// - /// - /// A host deleted since it was connected to falls through to the address, which is the honest answer: - /// the machine is still there and the keychain no longer knows about it. So does a host in a vault the - /// user has switched off, and for the same reason rather than by accident: selecting it would point the - /// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back - /// out — arriving at the hosts screen with nothing selected and no explanation. - /// - /// - [RelayCommand] - private void ConnectToRecent(ConnectionLogRowViewModel row) - { - if (row is null || Vault is not { } vault) - { - return; - } - - if (row.HostId is { } hostId - && vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known - && vault.IsVaultShown(known.VaultId)) - { - vault.AskAboutHostCommand.Execute(known); - ShowScreen(ShellScreen.Hosts); - return; - } - - vault.ManualTarget = row.Address; - vault.ManualStatus = string.Empty; - } - // ---- The rail's own page grouping ---- /// @@ -2602,10 +2551,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// A click on one of the recent connections printed under the prompt. /// /// - /// A row naming a host still in the keychain goes to that host, with whatever the keychain knows about - /// it; one that names none — or a host deleted since — is the address that was dialled, and asks for its - /// password the way a typed one does. The same split makes, and for its - /// reason, except that here the answer is a line of the prompt rather than another screen. + /// + /// Two answers, because a recent row is one of two different things. A row naming a host still in + /// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved + /// password, its refusals — exactly as typing its name would. One that names none was typed in as an + /// address, and the log stored exactly what was dialled, user@host:port, which is the grammar the + /// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the + /// password was never stored, which is the whole point of that path. + /// + /// + /// A host deleted since it was connected to falls through to the address, which is the honest answer: + /// the machine is still there and the keychain no longer knows about it. So does a host in a vault the + /// user has switched off, and for the same reason rather than by accident: taking it would reach a host + /// the rest of the application is deliberately not showing. + /// /// [RelayCommand(AllowConcurrentExecutions = true)] private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row) @@ -2807,13 +2766,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// /// The palette's answer to a machine that is not in the keychain. Every other row it offers is a /// saved host, and a box somebody has just been given the address of had no way in on this head short of - /// saving it first — the phone has had a manual box on its terminal screen all along. The query already - /// is a box, and user@host is the grammar people type into one, so the palette reads it rather - /// than growing a second field. + /// saving it first. The query already is a box, and user@host is the grammar people type into + /// one, so the palette reads it rather than growing a second field. /// /// - /// Read with , which is the phone's box's own parser, so - /// the two heads accept exactly the same addresses and refuse the rest with the same sentences. + /// Read with , which is the connect prompt's parser and + /// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses the rest + /// with the same sentences. /// /// /// Only a query with an @ in it is read at all. Without one, everything typed is a search, and a @@ -2884,7 +2843,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// The password typed for . /// - /// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box + /// Cleared whenever the palette closes or goes back to the query, on the same terms the connect prompt /// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the /// last one's password already in the box. /// @@ -3001,8 +2960,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// /// /// - /// Through , which is the phone's manual box's own - /// path, so a machine reached from here gets the same tab, the same host-key question and the same + /// Through , which is the connect prompt's own path, + /// so a machine reached from here gets the same tab, the same host-key question and the same /// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust /// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is /// exactly the one whose key nobody has seen before. diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index 5d4f20b..06a4436 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -4251,48 +4251,21 @@ internal sealed partial class VaultViewModel( [NotifyPropertyChangedFor(nameof(ShowsAddButton))] private bool isAskingForConnectPassword; - /// What was typed into the manual connect box, as user@host or user@host:port. - /// - /// One box rather than four, because this is the form of an address people already have: it is what a - /// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port - /// would make the ordinary case three taps between three keyboards on a phone. - /// - [ObservableProperty] - private string manualTarget = string.Empty; - - /// - /// - /// Its own box rather than , for the reason TryBuildConnectionRequest - /// takes the typed password as a parameter: these are different screens, and a password typed on one is - /// not a password offered on the other. - /// - [ObservableProperty] - private string manualPassword = string.Empty; - - /// Why the manual box refused, if it did. - /// - /// Beside that box rather than only on . The refusals here are about what was typed - /// — a missing account, a port that is not a number — and a sentence about a text box belongs next to - /// the text box, not on a status line that also carries what the sync engine is doing. - /// - [ObservableProperty] - private string manualStatus = string.Empty; - /// /// The machine the hosts search box names, when what was typed into it reads as user@host[:port]. /// /// /// - /// The phone's version of the desktop palette's typed-address row. The manual box on the terminal - /// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while - /// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the - /// design already calls it "Search or connect…", and user@host is what people type into a box, so - /// it reads the address rather than the screen growing a second field. + /// The phone's version of the desktop palette's typed-address row. The connect prompt on the + /// terminal screen is the other way in, and it is only drawn while nothing is open — a second machine + /// reached while a first one's session is up had no box at all. The search well at the top of HOSTS is + /// always there, the design already calls it "Search or connect…", and user@host is what people + /// type into a box, so it reads the address rather than the screen growing a second field. /// /// - /// Read with , the manual box's own parser, and only for a filter with an - /// @ in it: without one, everything typed is a search, and a card offering to dial three letters - /// would arrive on every keystroke. + /// Read with , the prompt's and the palette's parser too, and only for a + /// filter with an @ in it: without one, everything typed is a search, and a card offering to dial + /// three letters would arrive on every keystroke. /// /// [ObservableProperty] @@ -4318,10 +4291,9 @@ internal sealed partial class VaultViewModel( /// The password typed for . /// - /// Its own box rather than , for the reason that one is not - /// : these are different screens, and a password typed on one is not a - /// password offered on the other. Cleared when the address changes, so the next machine typed never - /// arrives with the last one's password already in the box. + /// Its own box rather than : these are different screens, and a password + /// typed on one is not a password offered on the other. Cleared when the address changes, so the next + /// machine typed never arrives with the last one's password already in the box. /// [ObservableProperty] private string filterAddressPassword = string.Empty; @@ -7820,40 +7792,6 @@ internal sealed partial class VaultViewModel( IsHostPaneOpen = true; } - /// - /// Raises whatever the head in front of the user uses to ask about one host. - /// - /// The machine, which the caller is holding. - /// - /// - /// ◆ One command, two pieces of furniture, and it exists because the phone's has changed. The - /// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag - /// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and - /// that is a different piece of state, so a caller that only opened the pane would leave this head - /// arriving at a list with nothing on it to press. - /// - /// - /// Both are set rather than branching on which head is running, because neither view model knows and - /// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the - /// phone has no drawer. - /// - /// - /// The one caller is MainWindowViewModel.ConnectToRecent, and what it is doing is the deliberate - /// act both flags exist to tell apart from browsing. - /// - /// - [RelayCommand] - private void AskAboutHost(HostRowViewModel? row) - { - if (row is null) - { - return; - } - - OpenHostPane(row); - ChooseHost(row); - } - /// /// Puts the drawer away, whichever of the three panels is in it. /// @@ -11529,62 +11467,13 @@ internal sealed partial class VaultViewModel( cancellationToken).ConfigureAwait(true); } - /// - /// Opens a terminal on somewhere that is not in the keychain. - /// - /// - /// - /// The one connection this application makes to a machine it has never been told about. Everything - /// else starts from a keychain item, and that is still the way a host anybody uses twice should be - /// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is - /// not typed again. This is for the other case, which is real and had no answer: a box somebody has just - /// been given the address of. - /// - /// - /// A typed password and nothing else. Offering the keychain's keys here would be a second binding - /// resolution beside , and the argument against a second one is - /// written there at length. A key is a reason to save the host. - /// - /// - /// Nothing is written to the keychain, deliberately. What is written, if the user answers the - /// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and - /// a machine reached this way is exactly the one whose key nobody has seen before. - /// - /// - /// It takes no cancellation token and allows concurrent executions, for the two reasons - /// carries. - /// - /// - [RelayCommand(AllowConcurrentExecutions = true)] - private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None); - - /// - private async Task ConnectManuallyAsync(CancellationToken cancellationToken) - { - if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal)) - { - ManualStatus = refusal; - return; - } - - if (ManualPassword.Length == 0) - { - ManualStatus = "A password is needed. Save this machine as a host to reach it with a key."; - return; - } - - ManualStatus = string.Empty; - - await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true); - } - /// /// Connects to the address typed into the hosts search box, with the password typed under it. /// /// /// /// Through , so the tab, the host-key question and the connection-log - /// entry are the ones the manual box and the desktop palette produce, and nothing is written to the + /// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the /// keychain. /// /// @@ -11654,14 +11543,27 @@ internal sealed partial class VaultViewModel( /// /// /// - /// The second half of , split out for the desktop's - /// quick-connect palette. That head has no manual box for to be typed into — - /// the palette's own query is the box — so it parses with and hands - /// over what it read rather than writing into the phone's fields to have them read back here. + /// The one connection this application makes to a machine it has never been told about. Everything + /// else starts from a keychain item, and that is still the way a host anybody uses twice should be + /// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is + /// not typed again. This is for the other case, which is real and had no answer: a box somebody has just + /// been given the address of. /// /// - /// The caller has already refused an unreadable address and an empty password, which is why neither is - /// checked again: both refusals are sentences about a box, and only the caller knows which box. + /// A typed password and nothing else. Offering the keychain's keys here would be a second binding + /// resolution beside , and the argument against a second one is + /// written there at length. A key is a reason to save the host. + /// + /// + /// Nothing is written to the keychain, deliberately. What is written, if the user answers the + /// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and + /// a machine reached this way is exactly the one whose key nobody has seen before. + /// + /// + /// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts + /// search — parses it with and hands over what it read. The caller has + /// already refused an unreadable address and an empty password, which is why neither is checked again: + /// both refusals are sentences about a box, and only the caller knows which box. /// /// internal Task ConnectToEndpointAsync( @@ -12375,7 +12277,7 @@ internal sealed partial class VaultViewModel( // Last, and after the tab exists: keeping the password is a favour, and the session the user asked // for must not wait on a vault write to appear. // - // Only for a target that came from a keychain host. A machine typed into the manual box has nothing + // Only for a target that came from a keychain host. A machine typed in as an address has nothing // to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the // screen it is typed on says so. if (target is { Row: { } row, MayRememberPassword: true }) @@ -12702,7 +12604,7 @@ internal sealed partial class VaultViewModel( /// /// /// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is - /// reached with a key the item names, and a machine typed into the manual box has neither. + /// reached with a key the item names, and a machine typed in as an address has neither. /// internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null); } diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Forwards.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Forwards.cs index af580a6..a8ddbd9 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Forwards.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Forwards.cs @@ -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(); diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index 02564dd..3a32bbf 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -1040,19 +1040,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime /// /// 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. /// [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); } /// + /// /// ssh 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. + /// + /// + /// The first two have no @, 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. + /// /// [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"); } - /// - /// 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 TryBuildAuthentication exists to be the only copy of. - /// - [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(); - } - /// /// The phone's hosts search box, read as an address: the card that dials it appears for user@host /// and for nothing else. A plain search must not grow one on every keystroke. @@ -1188,8 +1170,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime } /// - /// 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. /// [Fact] public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress() @@ -1429,8 +1411,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime } /// - /// 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 TryBuildAuthentication exists to be the only + /// copy of. /// [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(); } /// @@ -1485,7 +1472,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime /// /// The retry used to re-run whichever host was selected. 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 } /// - /// 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. /// [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"); - } - - /// - /// 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. - /// - [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(); } /// @@ -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. /// [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(); } /// One row of the connection log, built by hand. @@ -3599,8 +3563,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime /// /// 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. /// [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; } + /// Types an address at the connect prompt, then its password, pressing Enter after each. + 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); + } + /// A transfers screen connected to the fixture's host, and the session it opened. private async Task ConnectedTransfersAsync() {