Open a new host in the vault of the group it is being made in

+ NEW HOST decided two defaults separately and let them contradict each other.
The group came from the screen — the selected card, or failing that the group
whose contents are showing — and the vault came from the keychain screen's
standing "new items go to" preference. Inside a group belonging to any other
vault the two disagreed, and the group is what lost: GroupInEditingVault drops a
group the editor's vault has not got, on the sound reasoning that a host filed
under an id its readers cannot resolve looks unfiled to everybody but the person
who wrote it. So pressing the button while standing inside a team's PLATFORM
opened a form filed under nothing, bound for the personal vault, with no sentence
anywhere saying either thing had happened.

The vault now follows the group. A group lives in exactly one vault, so a host
that is to land in that group has to be sealed in that vault too — which is the
rule + NEW GROUP has followed for a parent since the cards became a tree, and the
comment there claiming this as a deliberate difference from the host's editor is
the one the code has now caught up with.

The filter stays, because there is one case left for it: the group's vault may be
one this session can read and not write, a team vault this account is a viewer of.
TargetVaults is the readable-and-writable set and is what decides here, so a
viewer keeps the standing preference and loses the group with it, rather than
opening an editor aimed at a save that cannot happen.

Both directions are tested, since one alone would not say which default wins:
standing in a shared vault's group, the editor opens on that vault with the group
selected and the host saves there; and with the preference pointed at the shared
vault while a personal-vault group is open, the group beats the picker somebody
set once.
This commit is contained in:
2026-08-06 12:08:15 +02:00
parent f1d6499bb5
commit 808a9a7fc1
2 changed files with 113 additions and 14 deletions
@@ -1619,6 +1619,80 @@ public sealed class VaultSharingTests : IAsyncLifetime
.ShouldBeNull("the host is left where it was rather than filed under an unresolvable group");
}
/// <remarks>
/// <para>
/// The same rule as the drag above, arrived at from the other end: a group lives in exactly one vault, so
/// + NEW HOST inside one has to open on that vault as well as on that group. The two defaults were
/// decided separately — the group came from the screen, the vault from the standing "new items go to"
/// preference — so pressing the button inside a shared vault's group opened a form bound for the personal
/// vault, with the group silently dropped by the picker that keeps the two in step.
/// </para>
/// <para>
/// The second half is the same test read backwards, and it is what says which of the two wins. The
/// preference is an answer for a host being made from nowhere in particular; the group somebody is
/// standing inside is a better one, and it is the one the button was pressed in.
/// </para>
/// </remarks>
[Fact]
public async Task ANewHostInsideAGroup_IsMadeInThatGroupsVaultRatherThanTheStandingPreference()
{
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 a new group goes with nothing open and the preference
// untouched. It is the one the second half of this test stands in.
await AddGroupAsync(vault, "staging", under: null);
var personalVaultId = Named(vault, "staging").VaultId;
personalVaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with one vault");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.OpenGroupCommand.Execute(Named(vault, "production"));
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault.ShouldNotBeNull(vault.Status).VaultId
.ShouldBe(sharedVaultId, "the group the screen is standing in is in the shared vault");
vault.EditorSelectedGroup.ShouldNotBeNull().Label
.ShouldBe("production", "and the group survives, having a vault it can be resolved in");
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
var host = vault.Hosts.ShouldHaveSingleItem();
host.VaultId.ShouldBe(sharedVaultId, vault.Status);
host.Host.GroupId.ShouldBe(Named(vault, "production").EntityId);
// And backwards: the preference names the shared vault and the open group is in the personal one.
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
vault.OpenGroupCommand.Execute(Named(vault, "staging"));
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault.ShouldNotBeNull(vault.Status).VaultId
.ShouldBe(personalVaultId, "the group somebody is standing in beats the picker they set once");
vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("staging");
}
/// <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;