Merge branch 'claude/host-password-persistence-2c2c1f'

This commit is contained in:
2026-08-03 14:41:13 +02:00
4 changed files with 278 additions and 7 deletions
@@ -2661,6 +2661,123 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
}
// ---- Remembering a typed password ----
[Fact]
public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain()
{
var vault = await ReadyToConnectAsync();
vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision");
vault.ConnectPassword = "s3cret";
vault.RemembersConnectPassword = true;
await ConnectAndRememberAsync(vault);
// An ordinary keychain credential, named after the host, and carrying no username of its own — the
// connection that just succeeded used the host's, and pinning a copy of it here would stop following
// the host.
var stored = vault.Credentials.ShouldHaveSingleItem();
stored.Label.ShouldBe("prod-db");
stored.Credential.Password.ShouldBe("s3cret");
stored.Credential.Username.ShouldBeNull();
var host = vault.Hosts.ShouldHaveSingleItem();
host.Host.CredentialId.ShouldBe(stored.EntityId);
host.Authentication.ShouldBe("credential");
// The box has nothing left to hold and nothing left to ask, and the tick does not carry over to
// whatever host is selected next.
vault.ConnectPassword.ShouldBeEmpty();
vault.RemembersConnectPassword.ShouldBeFalse();
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
}
[Fact]
public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection()
{
// The whole point of storing it in the vault rather than on this machine: it is a property of the
// host that reaches the other machines, not a box this one happens to remember filling in.
var vault = await ReadyToConnectAsync();
// One renderer for both connections. The page's token is spent on the first attach, so a second
// FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason
// nothing else in this suite connects twice.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ConnectPassword = "s3cret";
vault.RemembersConnectPassword = true;
await vault.ConnectCommand.ExecuteAsync(null);
await vault.SyncCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
vault.SelectedHost = vault.Hosts[0];
vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now");
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.Count.ShouldBe(2);
ssh.Requests[1].Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
}
[Fact]
public async Task ARefusedConnection_RemembersNothing()
{
// The failure this feature could most easily cause: a typo bound to the host, which then stops asking
// and cannot be connected to until somebody works out that the keychain is where the wrong password
// now lives. Only a handshake the remote accepted is worth keeping.
var vault = await ReadyToConnectAsync();
ssh.Failure = new InvalidOperationException("authentication failed");
vault.ConnectPassword = "wrong";
vault.RemembersConnectPassword = true;
await vault.ConnectCommand.ExecuteAsync(null);
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
}
[Fact]
public async Task ConnectingWithoutTheTick_StoresNothing()
{
// The other half of the decision, and the reason the typed box still exists: a one-off password on a
// machine somebody will never open again must not end up synchronised to every device they own.
var vault = await ReadyToConnectAsync();
vault.ConnectPassword = "s3cret";
await ConnectWithRendererAsync(vault);
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed");
}
[Fact]
public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword()
{
// A tick left over from a host that did ask must not manufacture a credential out of a stored one's
// password — which is what reading the dialled secret without checking the binding would do.
var vault = await ReadyToConnectAsync();
await AddCredentialAsync(vault, "prod deploy", password: "s3cret");
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
vault.SelectedHost = vault.Hosts[0];
vault.RemembersConnectPassword = true;
await ConnectWithRendererAsync(vault);
vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain");
}
/// <remarks>
/// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> refuses a host naming
/// both a key and a credential, so two pickers would have been able to express the state and would have had
@@ -5088,6 +5205,24 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("Connected", Case.Insensitive);
}
/// <summary>
/// The same, for a connection that is expected to store its password.
/// </summary>
/// <remarks>
/// Without the status assertion, and that is the whole reason it is separate. Remembering writes two
/// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own
/// count — leaving "Connected" true of what happened and false of what the line says. What the connection
/// actually did is asserted on the vault, which is where it is durable.
/// </remarks>
private async Task ConnectAndRememberAsync(VaultViewModel vault)
{
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{