Merge branch 'claude/edit-screen-refresh-items-63a808'
ci / build and test (push) Successful in 2m6s
ci / android head (push) Successful in 3m11s
ci / desktop nightly (push) Successful in 45s
ci / api image (push) Successful in 31s

This commit is contained in:
2026-08-06 12:08:55 +02:00
5 changed files with 342 additions and 4 deletions
@@ -2494,6 +2494,130 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("host");
}
// ---- Keeping an open editor's pickers in step with the vault ----
/// <remarks>
/// The other side of the split guard, and the bug it left behind. The host editor and the keychain's
/// editors are on different screens and refuse each other no longer, so a key is very often added
/// <em>because</em> the host in front of the user needs one — with that host's editor still standing on the
/// Hosts screen. The picker was a snapshot taken when the editor opened, so the key never appeared in it
/// and the only way to reach it was to abandon the edit and start again.
/// </remarks>
[Fact]
public async Task AKeyAddedWithTheHostEditorOpen_AppearsInItsAuthenticationPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorPort = 2244;
await AddKeyAsync(vault, "deploy");
vault.IsEditing.ShouldBeTrue("adding a key must not close the host editor");
vault.EditorPort.ShouldBe(2244, "nor discard what has been typed into it");
var offered = vault.EditorAuthenticationChoices.Single(
choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal));
offered.Kind.ShouldBe(AuthenticationKind.SshKey);
offered.EntityId.ShouldBe(vault.Keys[0].EntityId);
// A real entry rather than a label: choosing it and saving is what the user came here to do.
vault.EditorSelectedAuthentication = offered;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(vault.Keys[0].EntityId);
vault.Hosts[0].Host.Port.ShouldBe(2244);
}
/// <remarks>
/// The counterweight, and the reason the refill restores each picker by id rather than reloading the
/// stored host: a list that grows under somebody halfway through a form must not move what they had
/// already chosen in it, and the save that follows must not rebind the host to something nobody picked.
/// </remarks>
[Fact]
public async Task AnItemArrivingWhileTheHostEditorIsOpen_LeavesItsSelectionWhereItWas()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
await BindKeyAsync(vault, vault.Hosts[0], keyId);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
await AddCredentialAsync(vault, "pg-primary");
vault.EditorAuthenticationChoices.ShouldContain(
choice => choice.Kind == AuthenticationKind.Credential);
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId
.ShouldBe(keyId, "a credential appearing must not unbind the host from its key");
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
vault.Hosts[0].Host.CredentialId.ShouldBeNull();
}
/// <remarks>
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the
/// chips — and a chip that only appeared on the next open would send the user round the same detour.
/// </remarks>
[Fact]
public async Task ATagAddedWithTheHostEditorOpen_AppearsAmongItsChips()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
await AddTagAsync(vault, "production");
vault.HasTagChoices.ShouldBeTrue();
var chip = vault.EditorTagChoices.ShouldHaveSingleItem();
chip.Label.ShouldBe("production");
chip.IsWorn.ShouldBeFalse("appearing is not the same as being put on");
}
/// <remarks>
/// The group editor shares the drawer with the host editor and its own picker was the same snapshot, so
/// the same detour applied to the default binding a whole group of hosts inherits.
/// </remarks>
[Fact]
public async Task AKeyAddedWithTheGroupEditorOpen_AppearsInItsDefaultBindingPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddGroupAsync(vault, "platform");
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
vault.EditGroupCommand.Execute(null);
vault.GroupEditorDefaultPort = 2222;
await AddKeyAsync(vault, "deploy");
vault.IsEditingGroup.ShouldBeTrue("adding a key must not close the group editor");
vault.GroupEditorDefaultPort.ShouldBe(2222, "nor discard what has been typed into it");
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single(
choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal));
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.Groups.ShouldHaveSingleItem().Group.DefaultSshKeyId.ShouldBe(vault.Keys[0].EntityId);
vault.Groups[0].Group.DefaultPort.ShouldBe(2222);
}
// ---- Binding a key to a host ----
[Fact]
@@ -319,6 +319,95 @@ public sealed class VaultSharingTests : IAsyncLifetime
shell.Vault.HasVaultChoice.ShouldBeTrue();
}
/// <remarks>
/// <para>
/// And it reaches a host editor that was already open, which is very nearly the only way a vault gets made:
/// somebody starts adding a host, realises it belongs to the team rather than to them, and goes to make
/// somewhere to put it. The editor's picker is its own — see <c>VaultViewModel.EditorVaultChoices</c> — and
/// it was filled when the editor opened, so the vault they had just made for this host was the one place
/// they could not file it without throwing the form away.
/// </para>
/// <para>
/// The second half is what stops the fix being worse than the bug: the refill adds the entry and leaves the
/// selection alone, so a vault appearing cannot move a half-typed host into it.
/// </para>
/// </remarks>
[Fact]
public async Task AVaultCreatedWithAHostEditorOpen_IsOfferedAsSomewhereToFileThatHost()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await CreateVaultAsync(shell.Vaults, "Platform secrets");
var vaultId = shell.Vaults.SelectedVault!.VaultId;
vault.IsEditing.ShouldBeTrue("making a vault must not close the host editor");
vault.EditorLabel.ShouldBe("prod-db", "nor discard what has been typed into it");
vault.ShowsEditorVaultChoice.ShouldBeTrue(
"a second writable vault is what makes the picker worth drawing at all");
vault.EditorVaultChoices.Select(choice => choice.VaultId).ShouldContain(vaultId);
vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal
.ShouldBeTrue("the refill must not move the host into the vault that has just appeared");
// Choosing it, on the other hand, files the host there — which is what the picker was for.
vault.EditorSelectedVault = vault.EditorVaultChoices.Single(
choice => choice.VaultId == vaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().VaultId.ShouldBe(vaultId, vault.Status);
}
/// <remarks>
/// The snippet editor's picker is a copy of the same list — see
/// <c>SnippetsViewModel.BuildEditorVaultChoices</c> — and went stale in exactly the same way. It watches the
/// list rather than the reload, because this screen has always been a wrapper over the vault's collections
/// and has no reload of its own to hang off.
/// </remarks>
[Fact]
public async Task AVaultCreatedWithASnippetEditorOpen_IsOfferedAsSomewhereToFileThatSnippet()
{
await UnlockedAsync();
var vault = shell.Vault!;
var snippets = SnippetsOver(vault);
snippets.NewCommand.Execute(null);
snippets.EditorLabel = "restart the api";
snippets.EditorCommand = "sudo systemctl restart dodossh-api";
await CreateVaultAsync(shell.Vaults, "Platform secrets");
var vaultId = shell.Vaults.SelectedVault!.VaultId;
snippets.IsEditing.ShouldBeTrue("making a vault must not close the snippet editor");
snippets.EditorCommand.ShouldBe(
"sudo systemctl restart dodossh-api",
"nor discard what has been typed into it");
snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between now");
snippets.EditorVaultChoices.Select(choice => choice.VaultId).ShouldContain(vaultId);
snippets.EditorSelectedVault.ShouldNotBeNull().IsPersonal
.ShouldBeTrue("the refill must not move the snippet into the vault that has just appeared");
snippets.EditorSelectedVault = snippets.EditorVaultChoices.Single(
choice => choice.VaultId == vaultId);
await snippets.SaveCommand.ExecuteAsync(null);
Snippet(snippets, "restart the api").VaultId.ShouldBe(vaultId, snippets.Status);
}
/// <remarks>
/// Making a vault makes exactly one membership list, and this is the assertion that the two-step create
/// has not started leaking them: the screen no longer offers to make one on its own, so a second one