Public Access
Draw the tags that have been storable and invisible since the domain landed
`Tag` has been a full item kind for three commits — a table, a migration, a codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two people tagging one host both keep theirs. Nothing drew a chip. The tags a client could store were ones nothing here could see. Chips on host rows, both heads, from names resolved through the tag list rather than ids: a tag that does not resolve is left out rather than drawn, because it means the tag was deleted elsewhere or belongs to a vault this session cannot read, and a host with one chip fewer is the honest answer where a host wearing a GUID is not. The id stays on the host, so the chip comes back if the tag does. The picker is chips that toggle, matching the chips on the row behind it. A list of names to tick would make the user match an entry to a chip they can see two inches away. The box under it creates a tag and puts it on straight away, because that is when a tag is usually wanted — while tagging a host and finding it does not exist yet. Unlike every other field in that editor it writes to the keychain immediately, since a host can only name an id that exists; cancelling therefore leaves the tag behind, which is honest rather than hidden. A name that already exists is used rather than repeated: two tags called "staging" are storable and must stay storable, because two people creating one offline is how it happens, but typing it into a box beside a chip of the same name is a slip. Renaming and deleting needed a home, or the picker fills with names nobody uses and never empties. That home is a TAGS category on the keychain screen, where every other item kind is managed — and renaming is the whole reason a tag is an item rather than a string repeated inside twenty payloads: it is one write, and no host is touched. The delete confirmation counts the hosts wearing it, which is the difference between a tidy-up and losing a filter somebody relies on. The desktop host editor now scrolls, and that is not a tidy-up. A picker's height is a chip per tag in the keychain, wrapped, so somebody with fifteen tags has an editor half again as tall as somebody with three; no fixed height holds that, and trimming other fields to buy room only moves the failure to whoever has sixteen. The layout suite caught it the moment its seeder grew tags — which is why the seeder now creates ten rather than three, enough to drive the pane onto its cap so the capped shape is what gets measured rather than one no real keychain produces. The cost is named where it is paid: the harness skips anything inside a ScrollViewer, so from here it certifies that pane fits the column rather than that every field in it does. Two smaller things fell out. Five buttons overflowed the keychain header by a few pixels, so GENERATE lost the word KEY — its tooltip carries what the word did. And TotalItemCount had been counting keys and credentials while ALL showed four kinds; it counts all five now, because a number under a chip that disagrees with the rows it opens is worse than no number. An adversarial review of this change found two defects it had introduced, both green against the full suite. NewTag filed into the "new items go to" picker while the tag list only ever holds the active vault's — so with a team vault selected a tag would be created, queued for push, reported as added, and then invisible, with no row, no count, no picker entry and nothing able to rename or delete it, because there is no active-vault switcher to go and find it with. The comment on the host editor's own create path states that exact rule; this was the one place that broke it, and NewObjectStore, whose list is likewise active-vault-only, already ignored the picker. And the tag editor was the only one of five that did not disarm a pending deletion when it opened, so arming a key's deletion and then pressing + TAG left a live DELETE for an item the user was no longer looking at, directly above the boxes they were typing into. Both are fixed, both have a test, and the first was checked against the broken version before being kept. The same review caught a doc comment that had been inserted between SnippetRowViewModel's summary and its declaration, silently taking it over. Verified by the whole suite on a clean build: 1413 tests over nineteen projects, none failing. Both heads build. The rectangles the layout suite cannot reach are phase 9 of docs/manual-checks.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3787,6 +3787,260 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Tags ----
|
||||
//
|
||||
// The type has been storable since the domain landed and unreachable until now. What these pin is the
|
||||
// two halves of making it reachable: a chip is a name resolved through the tag list, and a picker is a
|
||||
// set the host editor edits like any other field.
|
||||
|
||||
[Fact]
|
||||
public async Task ATagPutOnAHost_ShowsAsAChipAndSurvivesAReload()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
|
||||
Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]);
|
||||
Host(vault, "prod-db").HasTags.ShouldBeTrue();
|
||||
Host(vault, "prod-db").Host.TagIds.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenamingATag_ChangesEveryChipAndRewritesNoHost()
|
||||
{
|
||||
// The whole reason a tag is an item rather than a string on a host. If this ever needed to touch a
|
||||
// host, the type would have earned nothing over repeating the name inside twenty payloads.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
await AddTagAsync(vault, "pci");
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
await TagAsync(vault, "stage-web", "pci");
|
||||
|
||||
var before = Host(vault, "prod-db").Host;
|
||||
|
||||
vault.SelectedTag = vault.Tags.Single();
|
||||
vault.EditTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = "pci-dss";
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").TagLabels.ShouldBe(["pci-dss"]);
|
||||
Host(vault, "stage-web").TagLabels.ShouldBe(["pci-dss"]);
|
||||
|
||||
Host(vault, "prod-db").Host.ShouldBe(before, "renaming a tag must not rewrite a host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingATag_LeavesTheHostsAloneAndTheChipsGone()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
|
||||
var wornBefore = Host(vault, "prod-db").Host.TagIds;
|
||||
|
||||
vault.SelectedTag = vault.Tags.Single();
|
||||
vault.DeleteTagCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Usage
|
||||
.ShouldContain("1 host", Case.Insensitive, "the count is what makes this decidable");
|
||||
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Tags.ShouldBeEmpty();
|
||||
|
||||
// The chip is gone and the id is not. Nothing rewrites N payloads inside one delete, so the host
|
||||
// still names a tag that resolves to nothing — and would wear it again if the tag came back.
|
||||
Host(vault, "prod-db").TagLabels.ShouldBeEmpty();
|
||||
Host(vault, "prod-db").Host.TagIds.ShouldBe(wornBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATagCreatedFromTheHostEditor_IsPutOnTheHostBeingEdited()
|
||||
{
|
||||
// Where a tag is usually wanted: while tagging a host and finding it does not exist yet.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.HasTagChoices.ShouldBeFalse("nothing to offer in a keychain with no tags");
|
||||
|
||||
vault.EditorNewTag = "eu-west-1";
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(null);
|
||||
|
||||
vault.EditorNewTag.ShouldBeEmpty("the box empties so a second one can be typed straight away");
|
||||
vault.EditorTagChoices.ShouldHaveSingleItem().IsWorn.ShouldBeTrue();
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").TagLabels.ShouldBe(["eu-west-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATagTypedTwice_IsUsedRatherThanRepeated()
|
||||
{
|
||||
// Two tags called "staging" are storable and must stay storable — two people creating one offline
|
||||
// is how it happens. Typing the same name into this box is a slip rather than an intention.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewTag = "PCI";
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Tags.ShouldHaveSingleItem().Label.ShouldBe("pci", "matched regardless of case");
|
||||
vault.EditorTagChoices.ShouldHaveSingleItem().IsWorn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATagTakenOffAHost_LeavesTheTagInTheKeychain()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.ToggleEditorTagCommand.Execute(vault.EditorTagChoices.Single());
|
||||
vault.EditorTagChoices.Single().IsWorn.ShouldBeFalse();
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").TagLabels.ShouldBeEmpty();
|
||||
vault.Tags.ShouldHaveSingleItem().HostCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellingAHostEdit_DropsTheTaggingAndKeepsTheTag()
|
||||
{
|
||||
// The one asymmetry worth pinning. Tagging is a field on the host and goes with a cancel; creating
|
||||
// the tag wrote to the keychain immediately, because a host can only name an id that exists.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewTag = "pci";
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(null);
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
vault.Tags.ShouldHaveSingleItem().Label.ShouldBe("pci", "the tag was never part of the host");
|
||||
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
|
||||
{
|
||||
// BuildHost rebuilds the whole record from the editor's state, so a tag set that was not carried
|
||||
// through would be stripped by an edit about something else entirely.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorPort = 2222;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheKeychainTable_ShowsTagsAndCountsThemUnderAll()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
|
||||
vault.Section = VaultSection.Tags;
|
||||
|
||||
var row = vault.VaultItems.ShouldHaveSingleItem();
|
||||
row.Name.ShouldBe("pci");
|
||||
row.Type.ShouldBe("TAG");
|
||||
row.Detail.ShouldBe("1 host");
|
||||
|
||||
vault.Section = VaultSection.All;
|
||||
|
||||
vault.TotalItemCount.ShouldBe(
|
||||
vault.VaultItems.Count, "the ALL chip counts what ALL shows");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningTheTagEditor_TakesAnArmedDeletionAway()
|
||||
{
|
||||
// Every other editor on this screen disarms a pending question when it opens, because the vault
|
||||
// screen's Add buttons stay live beside the detail pane. Without it the tag's boxes would render
|
||||
// directly under a confirmation belonging to an item the user is no longer looking at, and its
|
||||
// DELETE would still be live.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
vault.Section = VaultSection.Keys;
|
||||
vault.SelectedVaultItem = vault.VaultItems.Single();
|
||||
vault.DeleteSelectedItemCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull();
|
||||
|
||||
vault.NewTagCommand.Execute(null);
|
||||
|
||||
vault.IsEditingTag.ShouldBeTrue();
|
||||
vault.PendingDeletion.ShouldBeNull("the question went with the editor opening");
|
||||
}
|
||||
|
||||
private static async Task AddTagAsync(VaultViewModel vault, string label)
|
||||
{
|
||||
vault.NewTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = label;
|
||||
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>Puts a tag on a host the way a user can: through the host's own editor.</summary>
|
||||
private static async Task TagAsync(VaultViewModel vault, string host, string tag)
|
||||
{
|
||||
vault.SelectedHost = Host(vault, host);
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.ToggleEditorTagCommand.Execute(vault.EditorTagChoices.Single(
|
||||
choice => string.Equals(choice.Label, tag, StringComparison.Ordinal)));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private static async Task SetGroupDefaultsAsync(
|
||||
VaultViewModel vault,
|
||||
string group,
|
||||
|
||||
@@ -238,6 +238,41 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
||||
row.VaultId.ShouldBe(teamVaultId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
||||
/// team 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
|
||||
|
||||
vault.SelectedTargetVault = vault.TargetVaults.Single(
|
||||
choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.NewTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = "eu-west-1";
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Tags.ShouldHaveSingleItem().Label
|
||||
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
|
||||
}
|
||||
|
||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||
{
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
Reference in New Issue
Block a user