Merge branch 'claude/groups-vault-sharing-e4b154'
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 2s

# Conflicts:
#	src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
This commit is contained in:
2026-08-04 16:28:29 +02:00
8 changed files with 765 additions and 112 deletions
@@ -727,12 +727,216 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
}
/// <remarks>
/// <para>
/// A group is a shelf, and a shared vault is what makes it everybody's shelf. The assertions are the
/// three things that were missing while the group list was the active vault's alone: it is listed at
/// all, the row says which vault it is in, and a rename typed into it goes back to that vault rather
/// than forking a second group of the new name into the personal one.
/// </para>
/// <para>
/// Reloaded between the write and the read, so what is asserted is what came back out of the vault
/// rather than the row the save left behind.
/// </para>
/// </remarks>
[Fact]
public async Task AGroupFiledIntoASharedVault_IsListedThereAndRenamedThere()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewGroupCommand.Execute(null);
vault.ShowsGroupEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
var group = vault.Groups.ShouldHaveSingleItem();
group.VaultId.ShouldBe(sharedVaultId, vault.Status);
group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");
vault.SelectedGroup = group;
vault.EditGroupCommand.Execute(null);
vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
vault.DrawerSubtitle.ShouldBe(
"Platform secrets", "with no picker drawn, the header is what says whose shelf this is");
vault.GroupEditorLabel = "live";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
var renamed = vault.Groups.ShouldHaveSingleItem();
renamed.Label.ShouldBe("live");
renamed.VaultId.ShouldBe(sharedVaultId, "a rename must not fork a copy into the personal vault");
}
/// <remarks>
/// <para>
/// The group editor's picker is the group's, exactly as the host editor's is the host's: moving it must
/// not move the keychain screen's standing preference, and moving that one must not move a group
/// half-typed here.
/// </para>
/// <para>
/// The second half is the one worth the test. The picker is read when the form opens and the vault is
/// captured there, so a click on the other screen between typing the name and pressing ADD cannot
/// redirect the group somebody was making.
/// </para>
/// </remarks>
[Fact]
public async Task TheGroupEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
{
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 personal = vault.SelectedTargetVault!;
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
// Moved back after the editor opened, the way a click on the keychain screen would. The group must
// still land in the shared vault.
vault.SelectedTargetVault = personal;
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(sharedVaultId, vault.Status);
vault.SelectedTargetVault.ShouldBe(
personal, "the editor's picker is the group's, not the screen's standing preference");
}
/// <remarks>
/// A parent belongs to one vault, and a group filed under one in another vault would be a level half
/// the people holding the key cannot resolve — their hosts would inherit a port and a username from
/// nothing. The same rule the host editor's group picker follows, one level up the same tree.
/// </remarks>
[Fact]
public async Task AGroupsParentPicker_OffersOnlyTheVaultItIsGoingInto()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
// In the personal vault, which is where the standing preference points.
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "estate";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.Groups.ShouldHaveSingleItem().Label.ShouldBe("estate", vault.Status);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorParentChoices
.Any(choice => string.Equals(choice.Label, "estate", StringComparison.Ordinal))
.ShouldBeTrue("a group in the personal vault may be filed under a personal group");
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorParentChoices.ShouldHaveSingleItem()
.EntityId.ShouldBeNull("only 'no parent' is left once the group is going somewhere else");
}
/// <remarks>
/// <para>
/// Dragging a host card onto a group card is the one gesture that files a host without opening its
/// editor, and it can now be aimed across a vault boundary, because both grids draw every readable
/// vault. The write it would make is the exact thing the host editor's group picker was fixed to
/// prevent: an id only the other vault's holders can resolve.
/// </para>
/// <para>
/// Refused and said so, rather than quietly treated as "no group" — the user is plainly filing
/// something, and unfiling it instead would be the wrong answer delivered silently.
/// </para>
/// </remarks>
[Fact]
public async Task AHostDraggedOntoAnotherVaultsGroup_IsRefusedRatherThanFiledUnderIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
// The host stays in the personal vault, which is where a new one goes without being told otherwise.
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
var group = vault.Groups.Single(row => row.VaultId == sharedVaultId);
host.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group.EntityId));
vault.Status.ShouldContain("its own vault");
vault.Hosts
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
.Host.GroupId
.ShouldBeNull("the host is left where it was rather than filed under an unresolvable group");
}
/// <remarks>
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
/// shared vault has to stay there, because hosts are read across every readable vault and so come back.
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
/// shared vault has to stay there, because hosts are read across every readable vault and so come back;
/// so does a group, since its list spans them too. Tags are not — the editable list is the active
/// vault's alone, like buckets — so a tag filed anywhere else would be created, pushed, reported as
/// added and then invisible, with nothing on the keychain screen able to rename or delete it and no
/// active-vault switcher to go and find it with.
/// </remarks>
[Fact]
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()