Public Access
Compare commits
2
Commits
e750ba05e3
...
1e8a1f2e83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e8a1f2e83 | ||
|
|
4f9faa2fe3 |
+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
|
||||
|
||||
@@ -1474,6 +1501,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
|
||||
|
||||
@@ -9960,12 +9960,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