Merge branch 'claude/vault-key-sync-sharing-d098aa'
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 33s

This commit is contained in:
2026-08-06 07:39:32 +02:00
10 changed files with 1223 additions and 32 deletions
@@ -647,6 +647,236 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("only vault you can write to");
}
/// <remarks>
/// <para>
/// The gap the host's move kept running into. A key typed into a personal vault before the team existed
/// is the key the team's machines authenticate with, and until this existed there was no way to get it
/// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
/// second item and deleting the first.
/// </para>
/// <para>
/// <b>The re-aim is the half worth the test.</b> An item re-sealed into another vault lands with a new
/// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
/// something its vault no longer holds refuses to connect rather than falling back to a typed password.
/// A move that did only the first half would look like a success and break two machines.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");
vault.MoveSelectedItemCommand.Execute(null);
vault.IsMovingItem.ShouldBeTrue(vault.Status);
vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);
// The count, before the move rather than after it. Two machines stop connecting if this is wrong.
vault.MovingItemUsage.ShouldContain("2 hosts");
vault.SelectedMoveItemVault =
vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// As the host's move does: the pass that follows every write is made to fail, so the sentence the
// move itself wrote is still on the status line to be read.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveItemCommand.ExecuteAsync(null);
var moved = vault.Keys.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("2 hosts");
}
/// <remarks>
/// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
/// on working for the person who moved it either way — and for the colleagues it has just joined, a host
/// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
/// naming it by the id it landed with.
/// </remarks>
[Fact]
public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue(vault.Status);
vault.BindingToBringQuestion.ShouldContain("deploy");
vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
vault.BringsTheBindingAlong = true;
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var movedKey = vault.Keys.ShouldHaveSingleItem();
var movedHost = vault.Hosts.ShouldHaveSingleItem();
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
vault.Status.ShouldContain("came with it");
vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
}
/// <remarks>
/// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
/// and the sentence afterwards says what that means for everybody else in the destination. It is also
/// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
/// </remarks>
[Fact]
public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");
vault.Status.ShouldContain("another vault");
}
/// <remarks>
/// <para>
/// The case where a host stops connecting without naming anything. A group lends its default key to
/// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
/// a host that only inherited its key used to arrive naming nothing at all.
/// </para>
/// <para>
/// The binding is written onto the host on the way across instead, which is the same key it
/// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
/// binding, so an inherited key can be brought too.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
.Single(choice => choice.EntityId == key.EntityId);
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
var before = vault.Hosts.ShouldHaveSingleItem();
before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
before.Authentication.ShouldBe("key");
vault.SelectedHost = before;
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
}
/// <remarks>
/// <para>
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
@@ -1461,6 +1691,41 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// Through the form rather than straight at the command, because the name is what the form is for —
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
/// </remarks>
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
/// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
private static async Task<SshKeyRowViewModel> AddKeyAsync(VaultViewModel vault, string label)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = label;
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse(vault.Status);
return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
}
/// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.EntityId == keyId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
}
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{
await vaults.LoadAsync(Token);