Merge branch 'claude/trust-connect-popup-56abec'
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 43s
ci / build and test (push) Successful in 2m6s
ci / api image (push) Successful in 25s

This commit is contained in:
2026-08-06 12:27:20 +02:00
11 changed files with 609 additions and 75 deletions
+184 -10
View File
@@ -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]