Compare commits

..
2 Commits
Author SHA1 Message Date
jaap-jan 36b6ded63e Merge pull request 'Connections: drop the manual box's view-model state now that both heads ask through the prompt' (#24) from claude/charming-cerf-ae57b6 into main
ci / build and test (push) Successful in 3m46s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m28s
ci / desktop nightly (push) Successful in 3m27s
Reviewed-on: #24
2026-10-16 12:32:14 +00:00
jaap-jan fa85ad1eff Connections: drop the manual box's view-model state now that both heads ask through the prompt
ci / build and test (pull_request) Successful in 2m32s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m41s
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.
2026-10-16 14:21:10 +02:00
7 changed files with 142 additions and 313 deletions

No files matched your search

+9 -7
View File
@@ -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
@@ -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.
-->
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
/// <summary>The keychain host this was, if it was one.</summary>
/// <remarks>
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. 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 <c>VaultViewModel.ConnectToEndpointAsync</c>. 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 <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
/// </remarks>
internal Guid? HostId => entry.Secret.HostId;
@@ -1857,9 +1857,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// button needs a command.
/// </para>
/// <para>
/// 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
/// <see cref="IsConnectPromptShowing"/> — 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 <see cref="IsConnectPromptShowing"/> — which is worth being
/// able to reach deliberately.
/// </para>
/// </remarks>
[RelayCommand]
@@ -1946,57 +1946,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasRecentConnections));
}
/// <summary>Goes back to a machine that has been connected to before.</summary>
/// <remarks>
/// <para>
/// <b>Two destinations, because a recent row is one of two different things.</b> 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.
/// </para>
/// <para>
/// â—† <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> 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
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
/// </para>
/// <para>
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
/// <c>user@host:port</c>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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 ----
/// <summary>
@@ -2602,10 +2551,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// A click on one of the recent connections printed under the prompt.
/// </summary>
/// <remarks>
/// 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 <see cref="ConnectToRecent"/> makes, and for its
/// reason, except that here the answer is a line of the prompt rather than another screen.
/// <para>
/// <b>Two answers, because a recent row is one of two different things.</b> 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, <c>user@host:port</c>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
@@ -2807,13 +2766,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <para>
/// <b>The palette's answer to a machine that is not in the keychain.</b> 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 <c>user@host</c> 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 <c>user@host</c> is the grammar people type into
/// one, so the palette reads it rather than growing a second field.
/// </para>
/// <para>
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, 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 <see cref="VaultViewModel.TryParseManualTarget"/>, 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.
/// </para>
/// <para>
/// Only a query with an <c>@</c> 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
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
/// <remarks>
/// 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.
/// </remarks>
@@ -3001,8 +2960,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, 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 <see cref="VaultViewModel.ConnectToEndpointAsync"/>, 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.
@@ -4251,48 +4251,21 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isAskingForConnectPassword;
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private string manualTarget = string.Empty;
/// <inheritdoc cref="ConnectPassword" />
/// <remarks>
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
/// 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.
/// </remarks>
[ObservableProperty]
private string manualPassword = string.Empty;
/// <summary>Why the manual box refused, if it did.</summary>
/// <remarks>
/// Beside that box rather than only on <see cref="Status"/>. 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.
/// </remarks>
[ObservableProperty]
private string manualStatus = string.Empty;
/// <summary>
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The phone's version of the desktop palette's typed-address row.</b> 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 <c>user@host</c> is what people type into a box, so
/// it reads the address rather than the screen growing a second field.
/// <b>The phone's version of the desktop palette's typed-address row.</b> 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 <c>user@host</c> is what people
/// type into a box, so it reads the address rather than the screen growing a second field.
/// </para>
/// <para>
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
/// would arrive on every keystroke.
/// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, and only for a
/// filter with an <c>@</c> in it: without one, everything typed is a search, and a card offering to dial
/// three letters would arrive on every keystroke.
/// </para>
/// </remarks>
[ObservableProperty]
@@ -4318,10 +4291,9 @@ internal sealed partial class VaultViewModel(
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
/// <remarks>
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
/// <see cref="ConnectPassword"/>: 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 <see cref="ConnectPassword"/>: 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.
/// </remarks>
[ObservableProperty]
private string filterAddressPassword = string.Empty;
@@ -7820,40 +7792,6 @@ internal sealed partial class VaultViewModel(
IsHostPaneOpen = true;
}
/// <summary>
/// Raises whatever the head in front of the user uses to ask about one host.
/// </summary>
/// <param name="row">The machine, which the caller is holding.</param>
/// <remarks>
/// <para>
/// â—† <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
/// act both flags exist to tell apart from browsing.
/// </para>
/// </remarks>
[RelayCommand]
private void AskAboutHost(HostRowViewModel? row)
{
if (row is null)
{
return;
}
OpenHostPane(row);
ChooseHost(row);
}
/// <summary>
/// Puts the drawer away, whichever of the three panels is in it.
/// </summary>
@@ -11529,62 +11467,13 @@ internal sealed partial class VaultViewModel(
cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Opens a terminal on somewhere that is not in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// <b>The one connection this application makes to a machine it has never been told about.</b> 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.
/// </para>
/// <para>
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> 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.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
/// <inheritdoc cref="ConnectManuallyAsync()" />
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);
}
/// <summary>
/// Connects to the address typed into the hosts search box, with the password typed under it.
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="ConnectToEndpointAsync"/>, 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.
/// </para>
/// <para>
@@ -11654,14 +11543,27 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// <para>
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
/// over what it read rather than writing into the phone's fields to have them read back here.
/// <b>The one connection this application makes to a machine it has never been told about.</b> 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.
/// </para>
/// <para>
/// 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.
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> 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.
/// </para>
/// <para>
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
/// search — parses it with <see cref="TryParseManualTarget"/> 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.
/// </para>
/// </remarks>
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(
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
}
@@ -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();
+69 -101
View File
@@ -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()
{