fix(dashboard): de-duplicate dashboard_tags typed into the API-key create form

The form split tags with the shared ParseList and attached the result verbatim,
so "team-a, TEAM-A" persisted as two entries and the constraints column read
dashboard_tags=[team-a, TEAM-A] — one grant reported as two on the page whose
job is to show what a key was granted. Enforcement never saw it (a session holds
its tags in a case-insensitive set), which is exactly why the display was the
only place it could surface.

De-duplicates ordinal-ignore-case at the attach point only, first spelling
winning, matching ApiKeyAdminCommandLineParser.ParseDashboardTags. ParseList is
untouched: the five glob lists are matched literally, so near-duplicates there
are not necessarily the same rule and must survive verbatim — pinned by a test.

The help text claimed to mirror the CLI flag; it now claims only the shared
separators and the dedupe, since the form still drops an empty segment silently
where the CLI hard-fails. A browser form has no exit code to fail with, so that
difference stays, and Authorization.md now records it.
This commit is contained in:
Joseph Doherty
2026-08-18 05:19:02 -04:00
parent 1ea6f60ea2
commit fab600d3b0
3 changed files with 85 additions and 3 deletions
@@ -111,6 +111,72 @@ public sealed class ApiKeysPageDashboardTagsTests
Assert.False(request.Constraints.HasWriteConstraints);
}
/// <summary>
/// Two spellings of one tag persist as one grant, matching how
/// <c>ApiKeyAdminCommandLineParser.ParseDashboardTags</c> collapses them and how the
/// enforcement site compares them.
/// </summary>
/// <remarks>
/// Enforcement would survive the duplicate — a session holds its tags in a
/// case-insensitive set — but the API Keys page's constraints column would render
/// <c>dashboard_tags=[team-a, TEAM-A]</c> and report one grant as two, which is the
/// reading an audit surface can least afford.
/// </remarks>
[Fact]
public void TryBuildCreateRequest_DeduplicatesDashboardTagsIgnoringCase()
{
ApiKeysPage page = new();
page.CreateModel.DashboardTags = "team-a, TEAM-A, team-a";
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
// First spelling typed wins, so what the operator wrote is what the audit surface shows.
Assert.Equal(["team-a"], request.Constraints.DashboardTags);
}
/// <summary>
/// A dedupe that spanned fields would be a bug of its own: the five glob lists are matched
/// literally, so near-duplicates there are not necessarily the same rule and must survive
/// verbatim.
/// </summary>
[Fact]
public void TryBuildCreateRequest_DoesNotDeduplicateTheOtherConstraintLists()
{
ApiKeysPage page = new();
page.CreateModel.ReadSubtrees = "Area1, AREA1";
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
Assert.Equal(["Area1", "AREA1"], request.Constraints.ReadSubtrees);
}
/// <summary>
/// <c>Reset</c> clears the new field, so the next key minted in the same dialog does not
/// inherit the previous key's tag grant.
/// </summary>
[Fact]
public void Reset_ClearsDashboardTagsAlongsideTheOtherConstraintFields()
{
ApiKeysPage page = new();
page.CreateModel.DashboardTags = "team-a";
// A sibling field pins the assertion to Reset itself: were Reset a no-op, both would
// survive and the failure would name the method rather than the one line.
page.CreateModel.ReadSubtrees = "Area1";
page.CreateModel.KeyId = "leftover";
page.CreateModel.Reset();
Assert.Equal(string.Empty, page.CreateModel.DashboardTags);
Assert.Equal(string.Empty, page.CreateModel.ReadSubtrees);
Assert.Equal(string.Empty, page.CreateModel.KeyId);
}
/// <summary>An empty field leaves the key untagged rather than inventing a tag.</summary>
[Fact]
public void TryBuildCreateRequest_WhenDashboardTagsIsBlank_LeavesTheKeyUntagged()