Public Access
Merge branch 'claude/groups-vault-sharing-e4b154'
# Conflicts: # src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -451,8 +451,10 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
|
||||
/// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
|
||||
/// group and tag lists are all read from it alone. Switching it off would empty half the application
|
||||
/// rather than filter it, so the refusal says why instead of doing nothing.
|
||||
/// tag list are all read from it alone. Switching it off would empty half the application rather than
|
||||
/// filter it, so the refusal says why instead of doing nothing. The group list is no longer among them:
|
||||
/// it spans every readable vault, and hiding one drops that vault's cards and headings the way it drops
|
||||
/// its hosts.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePersonalVaultIsListedAndCannotBeHidden()
|
||||
@@ -471,6 +473,67 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
shell.StatusMessage.ShouldContain("always shown");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two halves of a group, and hiding a vault has to move exactly one of them. The cards and
|
||||
/// headings are a list a person reads, so a hidden vault's group leaves it — a folder that cannot be
|
||||
/// opened onto anything is worse than no folder. What a group also is is a port, a username and a
|
||||
/// binding lent to the hosts beneath it, and that must not move: those hosts are still in
|
||||
/// <c>Hosts</c>, which is what the connect path and the transfers screen read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted through the resolved port rather than through the map directly, because the resolved port
|
||||
/// is what a connection actually dials. A hidden vault whose hosts silently fell back to 22 would be
|
||||
/// this split having collapsed, and nothing on screen would say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HidingAVault_TakesItsGroupCardsButNotWhatItsHostsDial()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
var teamVaultId = await CreateVaultAsync("Platform secrets");
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.GroupEditorLabel = "production";
|
||||
vault.GroupEditorDefaultPort = 2222;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
await AddHostAsync(vault, teamVaultId, "prod-db", "db.internal");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(teamVaultId, vault.Status);
|
||||
|
||||
await HideAsync(teamVaultId);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.Groups.ShouldBeEmpty("a hidden vault's groups are cards onto hosts that are not drawn");
|
||||
|
||||
vault.Hosts
|
||||
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
|
||||
.Resolved.Port.Value
|
||||
.ShouldBe(2222, "hiding a vault must never change what one of its hosts dials");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
|
||||
/// that would decrypt, so a switch for it would do nothing at all.
|
||||
|
||||
Reference in New Issue
Block a user