Public Access
Ask about a host key where the connection was made, not on the host list
The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where the decision was, and charged every screen for it. It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after taking the box that dialled it away. So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what an operator published is a decision a person is entitled to make and the only moment they can make it; and the changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so deleting the navigation is what puts it over the Connections screen. IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A second connection can be refused while a first one is open, so this card is routinely raised over a live terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead. The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical wording here, and the second is the one that goes stale. TWO DEFECTS FOUND BEHIND IT. VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice over, and now says BACK. And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the occlusion rule said. It is gone, with a note pointing at the test that can fail on it. VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes. That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first: every other assertion about the flag reads it directly, and a direct read passes with the subscription deleted — while the card would never go away. The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves. ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect page would close it properly.
This commit is contained in:
+43
-4
@@ -74,10 +74,13 @@ pointer input matches a real mouse rather than a first look.
|
||||
|
||||
Press Ctrl+K from the FILES screen and connect to a host whose key is not yet approved.
|
||||
|
||||
**Pass:** the window lands on HOSTS with the fingerprint prompt visible and answerable.
|
||||
**Pass:** the fingerprint card comes up over the FILES screen, visible and answerable, and the screen
|
||||
underneath does not move — the transfer that was on it is still there when the question is answered.
|
||||
Approving connects; CANCEL leaves you on FILES.
|
||||
|
||||
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
|
||||
question that cannot be reached.
|
||||
question that cannot be reached — or the window has jumped to HOSTS, which is what it used to do and what
|
||||
cost the palette its whole point.
|
||||
|
||||
### 1.6 The vault menu draws above the terminal's rectangle · **the one with a precedent**
|
||||
|
||||
@@ -814,8 +817,32 @@ would look.
|
||||
|
||||
Connect to a host whose key is not yet approved.
|
||||
|
||||
**Pass:** no tab is left behind, and the window is on HOSTS with the fingerprint prompt. Approving it
|
||||
connects and gives the tab a pane. This is 1.5 with the tab rework underneath it.
|
||||
**Pass:** no tab is left behind, and the fingerprint card is over whatever the window was showing. Approving
|
||||
it connects and gives the new tab a pane. This is 1.5 with the tab rework underneath it.
|
||||
|
||||
### 7.4a A second connection's question, over a terminal that is open · **the occlusion one**
|
||||
|
||||
With one terminal open and showing, connect to a *different* host whose key is not yet approved.
|
||||
|
||||
**Pass:** the card is fully drawn and both its buttons take clicks. The terminal's rectangle is given up while
|
||||
it is up — the WebView is a native child window, so a card merely layered over it would be sliced at its left
|
||||
edge with TRUST AND CONNECT unreachable. Answering either way brings the pane back.
|
||||
|
||||
**Failure means:** `IsTerminalShowing` has stopped asking `IsHostKeyDecisionShowing`, and the most
|
||||
safety-critical question in the product is half-drawn and unanswerable. No test can see this — headless
|
||||
Avalonia has no native child window.
|
||||
|
||||
### 7.4b A changed host key can be got out of
|
||||
|
||||
Connect to a host whose pin no longer matches (approve a key, then change it on the remote).
|
||||
|
||||
**Pass:** the refusal is over the window with **no** way to continue — no "connect anyway", nothing that
|
||||
dismisses into a connection — and one control that puts it away. Pressing it leaves the screen that was
|
||||
underneath, and from there the host's editor and FORGET HOST KEY are reachable.
|
||||
|
||||
**Failure means:** the refusal is stuck on screen. It was, until `RejectHostKey` started clearing the
|
||||
mismatch as well as the pending key: on the phone that left an opaque full-screen panel over every screen the
|
||||
user went to next, including the editor it tells them to open.
|
||||
|
||||
### 7.5 Giving up on a connection, and what arrives afterwards
|
||||
|
||||
@@ -1455,6 +1482,18 @@ colour, with no tab and nothing dialled.
|
||||
**Failure means:** for the suggestion strip, the box lost `Classes="secret"` — see 10.1, and it is the same
|
||||
seriousness here, since this password is typed rather than stored and so is typed often.
|
||||
|
||||
### 11.7a A hand-typed machine's host key is judged on this screen · **the one the old arrangement got wrong**
|
||||
|
||||
From Connections with nothing open, type a machine this keychain has never reached and press CONNECT.
|
||||
|
||||
**Pass:** the UNKNOWN HOST KEY sheet rises **over this screen**. TRUST AND CONNECT opens the shell; CANCEL
|
||||
lowers the sheet and the box is still there, with what was typed still in it.
|
||||
|
||||
**Failure means:** if the window is on HOSTS instead, the shell is navigating there before letting the
|
||||
handshake ask — which is what it used to do, and it is at its worst precisely here: a machine reached from
|
||||
this box is deliberately in no keychain, so it was being judged on a list it does not appear on, with the box
|
||||
that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
|
||||
|
||||
### 11.8 A recent machine leads to the right place
|
||||
|
||||
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
|
||||
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
|
||||
sheet for that reason, it is a full-screen panel.
|
||||
|
||||
◆ WHAT IS BEHIND IT IS NO LONGER ALWAYS THE HOST LIST. The shell used to move to HOSTS before letting a
|
||||
handshake ask this, because the desktop head drew the question as a banner on that screen; both heads draw
|
||||
it over the surface now, so this is raised over whatever the user was on — including the connect box on the
|
||||
Connections surface, which is where a machine that is in no keychain at all is dialled from. Nothing here
|
||||
changes for that, and that is the point: this control never knew which screen it was over. See
|
||||
MainWindowViewModel.OnVaultConnectionFailed and HostKeyCard.axaml on the desktop.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
@@ -107,8 +114,15 @@
|
||||
<!--
|
||||
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
||||
where the accent button connects.
|
||||
|
||||
◆ IT SAID "BACK TO HOSTS", AND IT NEITHER WENT THERE NOR CAME BACK. Two things were wrong with
|
||||
it at once. RejectHostKey cleared only the pending key, so pressing it left HasHostKeyMismatch
|
||||
set and this opaque panel up over whatever the user navigated to next — including the host editor
|
||||
the paragraph above sends them to. And the shell no longer moves to Hosts to ask the question, so
|
||||
even fixed it does not land there: it lowers this panel and reveals the screen that was already
|
||||
underneath. "BACK" is what that is. See VaultViewModel.RejectHostKey.
|
||||
-->
|
||||
<Button Classes="secondary" Content="BACK TO HOSTS" Margin="0,24,0,0"
|
||||
<Button Classes="secondary" Content="BACK" Margin="0,24,0,0"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -256,6 +256,12 @@
|
||||
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
|
||||
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
|
||||
inside the hosts screen would be one the files screen could not raise.
|
||||
|
||||
◆ And that arrangement is what the shell now relies on rather than merely permits. It used to move to
|
||||
HOSTS before letting a handshake ask the question, because the desktop head drew it as a banner on that
|
||||
screen; it does not any more, so this sheet is what is over the Connections surface when a machine
|
||||
typed into the connect box by hand turns out to be a first contact. Which is the case the navigation
|
||||
was worst for — it took the box away. See MainWindowViewModel.OnVaultConnectionFailed.
|
||||
-->
|
||||
<Panel Grid.Row="1">
|
||||
<views:HostKeySheet DataContext="{Binding Vault}" />
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.HostKeyCard"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
The two host-key decisions, over whatever the window happens to be showing. The desktop counterpart of
|
||||
the phone's HostKeySheet, and one control for both answers for the same reason: they are one decision
|
||||
point, the view models make them mutually exclusive, and the shell must not be able to show both.
|
||||
|
||||
── IT WAS TWO BANNERS AT THE TOP OF THE HOSTS SCREEN. ───────────────────────────────────────────────
|
||||
Which is why the shell used to navigate there before letting the vault raise the question: a connection
|
||||
can be started from the palette on any screen, and a prompt behind the screen somebody is looking at is a
|
||||
prompt nobody can answer. The navigation covered that, and paid for it everywhere else — it took the user
|
||||
off the screen they were working on, and for the one connection that has no host at all it judged a
|
||||
machine typed in by hand on the list it is deliberately not on. See MainWindowViewModel's own note on
|
||||
OnVaultConnectionFailed.
|
||||
|
||||
Drawn over the surface instead, so nothing has to move and the palette's case needs no special one.
|
||||
|
||||
── THE OCCLUSION RULE, AND THIS IS THE CONTROL THAT MOST NEEDS IT. ──────────────────────────────────
|
||||
A second connection can be made while a first one is open, so this card is routinely raised over a live
|
||||
terminal — and that rectangle is a native child window which composites above everything Avalonia draws.
|
||||
A card layered over it would be sliced at its left edge with TRUST AND CONNECT unreachable, which for the
|
||||
most safety-critical question in the product is the worst place for that class of bug to land. The shell
|
||||
collapses the WebView while this is up; see MainWindowViewModel.IsHostKeyDecisionShowing.
|
||||
|
||||
── ◆ WHAT IS DELIBERATELY DIFFERENT BETWEEN THE TWO HALVES. ─────────────────────────────────────────
|
||||
First contact offers TRUST AND CONNECT, because deciding whether a fingerprint is the one the operator
|
||||
published is a judgement a person is entitled to make and is the only moment they can make it.
|
||||
|
||||
A changed key offers no way forward at all: no continue, no "connect anyway", and nothing that turns
|
||||
dismissing it into a connection. The only way past it is FORGET HOST KEY in the host's own editor, which
|
||||
is a deliberate act performed somewhere else — a button next to this warning would be "continue anyway"
|
||||
with two clicks instead of one. The phone's sheet carries the same note, and calls presenting this as
|
||||
dismissible the one design mistake here that matters.
|
||||
-->
|
||||
|
||||
<!--
|
||||
A wash with no press handler, and the omission is the point: every other overlay in this window closes by
|
||||
clicking away from it, and a question with two named answers must not be answerable by missing. The
|
||||
palette's own backdrop is the control to compare with — see QuickConnect.
|
||||
|
||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||
-->
|
||||
<Border Background="#CC0E1220">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||
<Border Classes="card" IsVisible="{Binding HasPendingHostKey}"
|
||||
BorderBrush="{StaticResource WarnSoft}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="label" Text="UNKNOWN HOST KEY" Foreground="{StaticResource Warn}" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource WarnText}" TextWrapping="Wrap">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" FontSize="10" Text="{Binding PendingHostKey.Algorithm}" />
|
||||
|
||||
<!--
|
||||
In full, wrapping rather than trimmed, and selectable. A fingerprint exists to be compared
|
||||
character by character against one an operator published, and the two things that ruin that are
|
||||
an ellipsis in the middle and a line you cannot copy.
|
||||
-->
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8">
|
||||
<SelectableTextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Warn}"
|
||||
Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Compare it with the fingerprint the server's operator published before trusting it. Trusting pins it for this keychain, on every machine." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ CHANGED HOST KEY ============ -->
|
||||
<Border Classes="card" IsVisible="{Binding HasHostKeyMismatch}"
|
||||
BorderBrush="{StaticResource DangerSoft}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="label" Text="HOST KEY CHANGED" Foreground="{StaticResource Danger}" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource Danger}" FontWeight="SemiBold" TextWrapping="Wrap"
|
||||
Text="The host key changed and the connection was refused." />
|
||||
|
||||
<!--
|
||||
The whole explanation comes from the view model, which already composes it — which host, both
|
||||
fingerprints, and which of them this keychain has pinned. Rewriting it here would be a second
|
||||
copy of the most safety-critical sentence in the product, in a second place to forget.
|
||||
-->
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8">
|
||||
<SelectableTextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource DangerText}"
|
||||
Text="{Binding HostKeyMismatch}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
|
||||
Text="If this server was rebuilt on purpose, edit the host and choose FORGET HOST KEY, then connect again. There is deliberately no way to continue from here." />
|
||||
|
||||
<!--
|
||||
One button, and it goes nowhere. It puts the refusal away so the host's editor can be reached; it
|
||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||
above, which connects.
|
||||
-->
|
||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,20 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The two host-key decisions, drawn over whatever the window is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault;
|
||||
/// which of the two halves draws is decided there and the two are mutually exclusive. Whether it is on screen
|
||||
/// at all is the shell's business — see <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>, which is also
|
||||
/// what collapses the terminal's WebView underneath it.
|
||||
///
|
||||
/// In its own file rather than in the window, like every other card here, because nothing inside that window
|
||||
/// can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostKeyCard : UserControl
|
||||
{
|
||||
public HostKeyCard() => InitializeComponent();
|
||||
}
|
||||
@@ -89,39 +89,19 @@
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
|
||||
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
|
||||
through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="16,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
◆ THE TWO HOST KEY PROMPTS WERE HERE, and they are now drawn over the window instead.
|
||||
|
||||
<Border Padding="16,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
They were banners on this screen, so the shell navigated to this screen before letting the vault
|
||||
raise one — because a connection can be started from the palette on any screen, and a question
|
||||
behind whatever somebody is looking at is a question nobody can answer. That covered the palette
|
||||
and charged everybody else for it: it took the user off the screen they were working on, and for a
|
||||
machine typed into the phone's connect box by hand it asked about it on the list it is deliberately
|
||||
not on. The decision is over the surface now, both heads, and nothing has to move. See
|
||||
HostKeyCard.axaml and MainWindowViewModel.OnVaultConnectionFailed.
|
||||
|
||||
Not copied into both places, deliberately. One prompt in two markups is two copies of the most
|
||||
safety-critical wording in the product, and the second is the one that goes stale.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
|
||||
@@ -348,6 +348,22 @@
|
||||
-->
|
||||
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
||||
|
||||
<!--
|
||||
◆ Later still, and the order between these two is a ranking rather than an accident: the palette is
|
||||
something the user opened and can close, and a host-key decision is a connection waiting on them. It
|
||||
used to be two banners at the top of the hosts screen, which is why the shell navigated there before
|
||||
the question could be asked — see HostKeyCard.axaml, and MainWindowViewModel.OnVaultConnectionFailed
|
||||
for what that cost every other screen.
|
||||
|
||||
Wrapped, like every other child here whose data context is the vault: the visibility is the shell's
|
||||
business — it is the flag that also collapses the WebView — and IsHostKeyDecisionShowing does not
|
||||
exist on a VaultViewModel, which with compiled bindings is a build error rather than a card that
|
||||
silently never appears.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsHostKeyDecisionShowing, FallbackValue=False}">
|
||||
<views:HostKeyCard x:Name="HostKeyPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -947,7 +947,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
|
||||
/// palette, and the phone's connect sheet.
|
||||
/// palette, the phone's connect sheet, and the host-key decision.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The host-key decision is the newest of them, and the one that made it a rule rather than a list.</b>
|
||||
/// It is drawn over whatever is on screen at the moment a handshake asks the question — including an open
|
||||
/// terminal, because a second connection can be being made while a first one is being typed into. Without
|
||||
/// this condition the two buttons that answer the most safety-critical question in the product would be
|
||||
/// sliced at the WebView's left edge and take no clicks. See <see cref="IsHostKeyDecisionShowing"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
|
||||
@@ -984,7 +991,36 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing =>
|
||||
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
|
||||
IsTerminalSurface
|
||||
&& !IsConnectSheetOpen
|
||||
&& !IsHostKeyDecisionShowing
|
||||
&& SelectedTab is { HasSession: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether a host key is waiting to be judged, over whatever the user is looking at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It is asked of the shell rather than of a screen because the answer decides an occlusion, and
|
||||
/// because the question is no longer tied to a screen.</b> The prompt used to be a banner at the top of
|
||||
/// the hosts screen, and the shell navigated there before letting the vault raise it — which is how a
|
||||
/// machine typed into the phone's own connect box came to be judged on a list it is deliberately not on.
|
||||
/// Both heads now draw the decision over whichever surface was showing when the handshake stopped, so
|
||||
/// there is nowhere it can be asked from that it cannot be answered on, and nothing has to move.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two states, one flag, and the views keep them apart: an unknown key is a decision with two buttons and
|
||||
/// a changed one is a refusal with no way forward. What they share is that both cover the rectangle the
|
||||
/// terminal would be in. See <c>HostKeyCard.axaml</c> on the desktop and <c>HostKeySheet.axaml</c> on the
|
||||
/// phone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The transfers screen's own copy of this question is not here, and that is not an omission: file
|
||||
/// transfer's prompt is drawn inside that screen, which is a page, so it occludes nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsHostKeyDecisionShowing =>
|
||||
IsUnlocked && Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal half of the window is the half being shown, pane or no pane.
|
||||
@@ -1623,13 +1659,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// The hosts page, because that is where this connection's questions get asked. An unknown or changed
|
||||
// host key is answered by a prompt drawn on that page and the palette opens from any screen, so
|
||||
// connecting from the files screen without this would leave the question behind the screen that asked
|
||||
// it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
|
||||
// need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
// ◆ IT USED TO GO TO THE HOSTS PAGE FIRST, and the reason it did no longer exists. An unknown or
|
||||
// changed host key was answered by a prompt drawn on that page, and the palette opens from any screen,
|
||||
// so connecting from the files screen without the jump would have left the question behind the screen
|
||||
// that asked it. Both heads draw the decision over the surface now — see IsHostKeyDecisionShowing —
|
||||
// and the other thing this connection can say before it dials, a refusal on Vault.Status, is in the
|
||||
// status bar, which is a row of the window rather than part of a screen.
|
||||
//
|
||||
// So the palette leaves the user where they were, which is the whole point of a palette: Ctrl+K over a
|
||||
// transfer that is still running should not cost the transfer's screen.
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
|
||||
@@ -2815,12 +2853,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasRecentConnections));
|
||||
|
||||
RaiseSyncState();
|
||||
|
||||
// The host-key decision is a property of the vault, so swapping the vault out is one of the ways it
|
||||
// stops being on screen — locking with a question still up is the case, and it is reachable: a
|
||||
// handshake that stopped on an unknown key does not hold the window, so the lock button is live
|
||||
// behind the card asking about it.
|
||||
RaiseTerminalState();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
|
||||
/// depth, which lives on the vault, and re-raising the shell's two derived properties on every
|
||||
/// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
|
||||
/// <para>
|
||||
/// Named properties are watched rather than all of them: the titlebar's sync state is the vault's outbox
|
||||
/// depth, which lives on the vault, and re-raising the shell's derived properties on every notification a
|
||||
/// busy vault produces would repaint the titlebar on every keystroke in an editor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two host-key flags are here because the decision the vault raises collapses this shell's WebView —
|
||||
/// see <see cref="IsHostKeyDecisionShowing"/>. It is a property rather than an event because it is a
|
||||
/// state the vault is in and not a moment: it is entered by a refused handshake, left by either answer,
|
||||
/// and cleared outright by forgetting a pin from a host's editor, which is a third caller that has
|
||||
/// nothing to do with connecting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
@@ -2829,6 +2882,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
e.PropertyName, nameof(VaultViewModel.HasPendingHostKey), StringComparison.Ordinal)
|
||||
|| string.Equals(
|
||||
e.PropertyName, nameof(VaultViewModel.HasHostKeyMismatch), StringComparison.Ordinal))
|
||||
{
|
||||
RaiseTerminalState();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
@@ -2913,11 +2974,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Answers a connection that did not become a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
|
||||
/// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
|
||||
/// now, and the status line they are not looking at is not where a failure should end. A host key
|
||||
/// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
|
||||
/// back where the question is being asked.
|
||||
/// now, and the status line they are not looking at is not where a failure should end. A host key question
|
||||
/// is not a refusal: nothing has gone wrong and there is nothing to report, so the tab goes and the
|
||||
/// decision takes its place until it is answered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It used to move the window, and that was the wrong half of the problem to solve.</b> The prompt was
|
||||
/// a banner on the hosts screen, so this navigated there — <c>Screen = Hosts</c>, <c>Surface = Page</c> —
|
||||
/// on the reasoning that a connection can be started from the palette on any screen and a question behind
|
||||
/// the screen somebody is looking at is a question nobody can answer. True, and answered the wrong way
|
||||
/// round: what it did to the one connection that has no host at all was to judge a machine typed into the
|
||||
/// phone's connect box on a list it is deliberately not on, after taking the box away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both heads now draw the decision over whatever is showing, so nothing has to move and the palette's
|
||||
/// case is covered without a special one. What is left here is the tab, and the surface it was on stays as
|
||||
/// it is — for the phone that surface <em>is</em> the connect box, and it is what the user comes back to
|
||||
/// whichever way they answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
|
||||
{
|
||||
@@ -2945,11 +3022,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
// The screen the question is drawn on, and the page rather than a terminal. A connection can be
|
||||
// started from the palette on any screen, so without this the prompt would be behind whatever the
|
||||
// user was looking at, with the connection waiting on an answer they cannot reach.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
// Nothing else. The decision is drawn over the surface the user is on rather than on a screen they
|
||||
// have to be taken to — see the remark — and the neighbour selected above is what that surface shows
|
||||
// once the question is answered. Its WebView is collapsed while the card is up, which is the one
|
||||
// thing a card in that rectangle cannot do for itself; see IsHostKeyDecisionShowing.
|
||||
RaiseTerminalState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3344,6 +3421,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsTerminalSurface));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
|
||||
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
||||
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||
|
||||
@@ -9849,12 +9849,37 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
||||
/// <summary>
|
||||
/// Dismisses whichever host-key decision is showing, without pinning or forgetting anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One command for both, because both are dismissals and the two states are mutually exclusive — which is
|
||||
/// what <c>TransfersViewModel.RejectHostKey</c> has always done for the same pair of cards. The mismatch
|
||||
/// has nothing else it may offer: withdrawing a pin is a deliberate act performed in the host's editor,
|
||||
/// away from the moment of connecting, and a button here would be "continue anyway" with two clicks
|
||||
/// instead of one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It used to clear only the pending key, which left the mismatch with no working exit.</b> That was
|
||||
/// invisible for as long as the refusal was a banner at the top of a screen — nothing was drawn over, so
|
||||
/// nothing was trapped, and the next connection attempt cleared it. It is not invisible now: both heads
|
||||
/// draw the refusal over the surface, and the phone's is an opaque full-screen panel whose one button runs
|
||||
/// this command. Leaving <see cref="HostKeyMismatch"/> set would have kept that panel up over every screen
|
||||
/// the user went to afterwards, including the host editor they were being sent to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
{
|
||||
var wasOffered = PendingHostKey is not null;
|
||||
|
||||
PendingHostKey = null;
|
||||
Status = "The host key was not trusted, so nothing was connected.";
|
||||
HostKeyMismatch = null;
|
||||
|
||||
Status = wasOffered
|
||||
? "The host key was not trusted, so nothing was connected."
|
||||
: "Nothing was connected.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -484,6 +484,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
// moved out from under it, and nothing in that window can be laid out here — so the connect banner, the
|
||||
// two host key prompts and the conflict log had never been through this harness at all. They are also
|
||||
// the four worst candidates for that: each appears only in a state somebody has to reproduce by hand.
|
||||
//
|
||||
// The host key prompts have since left this screen for a card over the whole surface, and their two tests
|
||||
// went with them; see the host key decision below.
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithNothingToAnnounce()
|
||||
@@ -499,23 +502,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the overview showing a host"));
|
||||
}
|
||||
|
||||
// ---- The host key decision ----
|
||||
//
|
||||
// ◆ These two were TheHostsScreenFits… tests, because the prompts were banners at the top of that screen
|
||||
// and the shell navigated there before either could be raised. They are a card over the whole surface
|
||||
// now — see HostKeyCard.axaml — so they are measured in the rectangle a card gets rather than in a
|
||||
// screen's rows, and they moved rather than being rewritten: the shapes worth measuring are the same two,
|
||||
// and each still only appears in a state somebody has to reproduce by hand.
|
||||
|
||||
/// <remarks>
|
||||
/// A full fingerprint is the widest line here and it must not be trimmed — the whole point of the card is
|
||||
/// that somebody can compare it character by character against what an operator published, and an
|
||||
/// ellipsis in the middle of one is worse than a card that does not fit, because it looks correct.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAHostKeyIsBeingApproved()
|
||||
public async Task TheHostKeyCardFitsWhileAKeyIsBeingApproved()
|
||||
{
|
||||
vault.PendingHostKey = new HostKeyPresentation(
|
||||
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the unknown-key prompt up"));
|
||||
await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the unknown-key decision up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The taller of the two, and the one whose height is not this control's to choose: the explanation is
|
||||
/// composed by the view model out of a host, a port and two full fingerprints, so it wraps to several
|
||||
/// lines and grows with the length of a hostname.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAHostKeyIsRefused()
|
||||
public async Task TheHostKeyCardFitsWhileAKeyIsRefused()
|
||||
{
|
||||
vault.HostKeyMismatch =
|
||||
"db.internal:22 presented ssh-ed25519 SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0, "
|
||||
+ "and this keychain has SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI pinned for it.";
|
||||
"The host key for db.production.internal:22 has changed. "
|
||||
+ "Pinned SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI, but the server offered "
|
||||
+ "SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0.";
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
|
||||
await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1463,6 +1485,35 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
||||
/// <remarks>
|
||||
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
|
||||
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
|
||||
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
|
||||
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
|
||||
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
|
||||
/// stop fitting when the strip is what it is drawn over.
|
||||
/// </remarks>
|
||||
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var card = new HostKeyCard { DataContext = vault };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasureImportAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
|
||||
@@ -1213,9 +1213,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other kind of not-connecting. An unknown host key is a question drawn on the hosts screen rather
|
||||
/// than a failure, so the tab goes and the window is put back where the question is — a tab saying the
|
||||
/// connection failed would be competing with the prompt that is about to resume it.
|
||||
/// The other kind of not-connecting. An unknown host key is a question rather than a failure, so the tab
|
||||
/// goes — a tab saying the connection failed would be competing with the decision that is about to resume
|
||||
/// it — and the window stays where it was, because the decision is drawn over whatever that is.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnUnknownHostKey_TakesTheTabAwayAndShowsTheQuestion()
|
||||
@@ -1234,13 +1234,109 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.Tabs.ShouldBeEmpty();
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
|
||||
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn there, and it has to be reachable");
|
||||
// It used to assert IsHostsShowing here, because the prompt was a banner on that screen and this
|
||||
// handler navigated to it. The screen is untouched now and the card is over it instead.
|
||||
shell.Screen.ShouldBe(ShellScreen.Transfers);
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue("and it has to be reachable from wherever the user is");
|
||||
|
||||
// And answering it connects, which is the whole reason the tab was not left saying it had failed.
|
||||
ssh.Failure = null;
|
||||
await vault.TrustHostKeyCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue();
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeFalse("the decision goes with the answer");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Refusing, and the state it leaves behind. Both halves have to clear: the changed-key refusal is drawn
|
||||
/// over the surface on both heads and the phone's is an opaque full-screen panel, so a dismissal that left
|
||||
/// the flag set would leave that panel up over every screen the user went to next — including the host
|
||||
/// editor it tells them to open. See VaultViewModel.RejectHostKey, which used to clear only the other one.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RefusingAHostKeyDecision_TakesItOffTheScreen(bool firstContact)
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Failure = firstContact
|
||||
? new SshHostKeyUnknownException(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"))
|
||||
: new SshHostKeyMismatchException(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
|
||||
"SHA256:the-pinned-key");
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue(shell.StatusMessage);
|
||||
|
||||
vault.RejectHostKeyCommand.Execute(null);
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeFalse();
|
||||
vault.HasHostKeyMismatch.ShouldBeFalse();
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeFalse();
|
||||
|
||||
// Nothing was pinned either way, so the machine is still a first contact next time.
|
||||
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one thing about the card that nothing else here can see, and the whole arrangement rests on it: the
|
||||
/// question belongs to the vault and the flag that draws the card — and collapses the terminal under it —
|
||||
/// belongs to the shell, so the shell has to re-raise it. Without that the card would never appear and,
|
||||
/// worse, never go away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing else in this suite watches <c>PropertyChanged</c>, and this is why it is worth being the first:
|
||||
/// every other assertion about the flag reads it directly, and a direct read passes with the subscription
|
||||
/// deleted. The departure is the half that matters — <c>OnVaultConnectionFailed</c> raises the state
|
||||
/// itself as the tab goes, so an arrival is announced twice over, and answering the question is announced
|
||||
/// only from the vault's own notification.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
var announcements = 0;
|
||||
shell.PropertyChanged += OnShellPropertyChanged;
|
||||
|
||||
try
|
||||
{
|
||||
ssh.Failure = new SshHostKeyUnknownException(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
announcements.ShouldBeGreaterThan(0, "the card has to be told to appear");
|
||||
|
||||
announcements = 0;
|
||||
vault.RejectHostKeyCommand.Execute(null);
|
||||
|
||||
announcements.ShouldBeGreaterThan(0, "and to go, which is the half a stale flag would trap");
|
||||
}
|
||||
finally
|
||||
{
|
||||
shell.PropertyChanged -= OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
void OnShellPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (string.Equals(
|
||||
e.PropertyName,
|
||||
nameof(MainWindowViewModel.IsHostKeyDecisionShowing),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
announcements++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1459,12 +1555,21 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette can be opened from any screen, and an unknown host key is answered by a prompt drawn on
|
||||
/// the hosts screen. Without this the connection would block on a question sitting behind whatever screen
|
||||
/// the user happened to be on.
|
||||
/// <para>
|
||||
/// The palette can be opened from any screen, and this used to be why an unknown host key moved the
|
||||
/// window: the prompt was a banner on the hosts screen, so the shell navigated there before letting the
|
||||
/// vault raise it, or the connection would have blocked on a question behind whatever the user was
|
||||
/// looking at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The decision is drawn over the surface now, on both heads, so nothing moves — and this is the same
|
||||
/// test inverted, kept rather than deleted because the requirement it was written for still holds. What
|
||||
/// changed is how it is met: the question has to be answerable from where the user is, not the user
|
||||
/// taken to where the question is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingFromThePalette_LandsOnTheHostsPageBeforeItCanBeRefused()
|
||||
public async Task ConnectingFromThePalette_AsksAboutTheHostKeyWithoutMovingTheWindow()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
@@ -1482,8 +1587,77 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
|
||||
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn on the hosts screen");
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
shell.Screen.ShouldBe(
|
||||
ShellScreen.Transfers, "the transfer the user was looking at is still what is underneath");
|
||||
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue("and the card is over it");
|
||||
|
||||
// No assertion about the renderer here, deliberately: this attempt's tab was the only one, so it is
|
||||
// collapsed for want of a session whatever the occlusion rule says, and a test that cannot fail is
|
||||
// worse than no test. That claim belongs where a live terminal is behind the card — see
|
||||
// AChangedHostKey_CollapsesTheTerminalItIsRefusedOver.
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
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);
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A changed key is the other half of the same control and the reason it collapses the renderer: a second
|
||||
/// connection can be refused while a first one is open, and the refusal has to be readable over it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AChangedHostKey_CollapsesTheTerminalItIsRefusedOver()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
// One live session first, so there is something in the rectangle for the refusal to be drawn over.
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue(shell.StatusMessage);
|
||||
|
||||
ssh.Failure = new SshHostKeyMismatchException(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
|
||||
"SHA256:the-pinned-key");
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.HasHostKeyMismatch.ShouldBeTrue();
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse("the card would otherwise be sliced at the WebView's edge");
|
||||
|
||||
// And it comes back when the refusal is put away, rather than needing a tab click to restore it.
|
||||
vault.RejectHostKeyCommand.Execute(null);
|
||||
|
||||
shell.IsHostKeyDecisionShowing.ShouldBeFalse();
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user