Dashboard: delete revoked API keys + confirm Rotate/Revoke/Delete

Add IApiKeyAdminStore.DeleteAsync that only deletes already-revoked
rows (active keys must be revoked first so the revoke event lands in
the audit log before the row disappears) and a matching admin-gated
DashboardApiKeyManagementService.DeleteAsync. ApiKeysPage now shows
Delete on revoked rows in place of the old "No actions" stub, and
Rotate/Revoke/Delete all route through ConfirmDialog so each
destructive action requires an explicit confirmation step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 07:30:30 -04:00
parent c5153d68bb
commit 24cc5fd0f0
7 changed files with 218 additions and 15 deletions
@@ -112,6 +112,56 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "rotated");
}
[Fact]
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
"operator01",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.DeleteCount);
}
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { DeleteResult = true };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("operator01", adminStore.LastDeletedKeyId);
Assert.Contains(auditStore.Entries, entry =>
entry.EventType == "dashboard-delete-key"
&& entry.KeyId == "operator01"
&& entry.Details == "deleted");
}
[Fact]
public async Task DeleteAsync_WhenStoreRefuses_ReportsFriendlyError()
{
FakeApiKeyAdminStore adminStore = new() { DeleteResult = false };
DashboardApiKeyManagementService service = CreateService(adminStore);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
}
/// <summary>
/// Server-004 regression: the dashboard create path must reject a request
/// carrying a non-canonical scope string rather than persisting a key whose
@@ -184,12 +234,18 @@ public sealed class DashboardApiKeyManagementServiceTests
public int RevokeCount { get; private set; }
public int DeleteCount { get; private set; }
public bool RevokeResult { get; init; }
public bool RotateResult { get; init; }
public bool DeleteResult { get; init; }
public string? LastRevokedKeyId { get; private set; }
public string? LastDeletedKeyId { get; private set; }
public byte[]? LastRotatedSecretHash { get; private set; }
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
@@ -225,6 +281,13 @@ public sealed class DashboardApiKeyManagementServiceTests
LastRotatedSecretHash = secretHash;
return Task.FromResult(RotateResult);
}
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
DeleteCount++;
LastDeletedKeyId = keyId;
return Task.FromResult(DeleteResult);
}
}
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
@@ -447,6 +447,11 @@ public sealed class DashboardSnapshotServiceTests
{
return Task.FromResult(false);
}
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
return Task.FromResult(false);
}
}
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore