feat(auth): cut MxGateway API keys over to ZB.MOM.WW.Auth.ApiKeys 0.1.2; keep constraint enforcement+gRPC+CLI on top (Task 1.3)

This commit is contained in:
Joseph Doherty
2026-06-02 02:08:38 -04:00
parent f4dc11bae4
commit 05009d7370
49 changed files with 515 additions and 1642 deletions
@@ -1,21 +1,37 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests
/// <summary>
/// Tests the gateway dashboard API-key management surface over the shared
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin commands and stores (the gateway is the donor). The service
/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
/// rule are all proven end-to-end.
/// </summary>
public sealed class DashboardApiKeyManagementServiceTests : IDisposable
{
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
@@ -23,17 +39,15 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.CreateCount);
Assert.Empty(await ListAsync(services));
}
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
/// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
[Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
{
FakeApiKeyAdminStore adminStore = new();
FakeApiKeyAuditStore auditStore = new();
FakeApiKeySecretHasher hasher = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
CreateAuthorizedUser(),
@@ -43,42 +57,46 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
string secret = result.ApiKey["mxgw_operator01_".Length..];
Assert.Equal(secret, hasher.LastSecret);
Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
Assert.Equal("operator01", stored.KeyId);
Assert.Equal("Operator", stored.DisplayName);
Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
Assert.Contains(auditStore.Entries, entry =>
entry.EventType == "dashboard-create-key"
&& entry.KeyId == "operator01");
// The freshly minted token authenticates against the same store and surfaces its scopes.
ApiKeyVerification verification = await services
.GetRequiredService<IApiKeyVerifier>()
.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);
// Constraints round-trip through the opaque JSON blob.
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry => entry.EventType == "dashboard-create-key" && entry.KeyId == "operator01");
}
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
[Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
"operator01",
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
CreateAuthorizedUser(),
CreateRequest(),
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.RevokeCount);
Assert.False(duplicate.Succeeded);
Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
CreateAuthorizedUser(),
@@ -86,21 +104,23 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("operator01", adminStore.LastRevokedKeyId);
Assert.Contains(auditStore.Entries, entry =>
ApiKeyListItem key = Assert.Single(await ListAsync(services));
Assert.NotNull(key.RevokedUtc);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-revoke-key"
&& entry.KeyId == "operator01"
&& entry.Details == "revoked");
}
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
/// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
FakeApiKeyAuditStore auditStore = new();
FakeApiKeySecretHasher hasher = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult created = await service.CreateAsync(
CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RotateAsync(
CreateAuthorizedUser(),
@@ -110,36 +130,28 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
Assert.Contains(auditStore.Entries, entry =>
Assert.NotEqual(created.ApiKey, result.ApiKey);
// Old token no longer authenticates; new one does.
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-rotate-key"
&& entry.KeyId == "operator01"
&& entry.Details == "rotated");
}
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
[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);
}
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { DeleteResult = true };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -147,27 +159,25 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("operator01", adminStore.LastDeletedKeyId);
Assert.Contains(auditStore.Entries, entry =>
Assert.Empty(await ListAsync(services));
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-delete-key"
&& entry.KeyId == "operator01"
&& entry.Details == "deleted");
}
/// <summary>
/// Tests-030: when the admin store refuses the delete (returns <c>false</c>), the service
/// still emits a <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c>
/// because <c>AppendAuditAsync</c> runs unconditionally after the store call. A regression that
/// moved the audit-append call inside the <c>if (deleted)</c> branch would silently drop the
/// audit trail for refused deletes — a real audit-completeness gap. This test pins both the
/// friendly-error response AND the unconditional audit entry.
/// When the key is still active (not revoked), the delete is refused but a
/// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
/// written — audit completeness for refused deletes.
/// </summary>
[Fact]
public async Task DeleteAsync_WhenStoreRefuses_ReportsFriendlyErrorAndAudits()
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { DeleteResult = false };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -177,17 +187,14 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.False(result.Succeeded);
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
ApiKeyAuditEntry auditEntry = Assert.Single(auditStore.Entries);
Assert.Equal("dashboard-delete-key", auditEntry.EventType);
Assert.Equal("operator01", auditEntry.KeyId);
Assert.Equal("not-found-or-active", auditEntry.Details);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
ApiKeyAuditEntry deleteEntry = Assert.Single(
audit, entry => entry.EventType == "dashboard-delete-key");
Assert.Equal("operator01", deleteEntry.KeyId);
Assert.Equal("not-found-or-active", deleteEntry.Details);
}
/// <summary>
/// Tests-030: <see cref="DashboardApiKeyManagementService.DeleteAsync"/> calls
/// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the
/// shared "API key id is required." message before any store or audit call runs.
/// </summary>
/// <summary>A blank key id fails validation before any store or audit call runs.</summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
[Theory]
[InlineData("")]
@@ -195,9 +202,8 @@ public sealed class DashboardApiKeyManagementServiceTests
[InlineData("\t")]
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
{
FakeApiKeyAdminStore adminStore = new();
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -205,20 +211,19 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.DeleteCount);
Assert.Empty(auditStore.Entries);
Assert.Empty(await ListAuditAsync(services));
}
/// <summary>
/// Server-004 regression: the dashboard create path must reject a request
/// carrying a non-canonical scope string rather than persisting a key whose
/// scope the authorization resolver never matches.
/// Server-004 regression: the dashboard create path must reject a request carrying a
/// non-canonical scope string rather than persisting a key whose scope the authorization
/// resolver never matches.
/// </summary>
[Fact]
public async Task CreateAsync_UnknownScope_DoesNotCallStore()
public async Task CreateAsync_UnknownScope_DoesNotCreate()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementRequest request = CreateRequest() with
{
@@ -233,25 +238,61 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.CreateCount);
Assert.Empty(await ListAsync(services));
}
private static DashboardApiKeyManagementService CreateService(
FakeApiKeyAdminStore? adminStore = null,
FakeApiKeyAuditStore? auditStore = null,
FakeApiKeySecretHasher? hasher = null)
private DashboardApiKeyManagementService CreateService(ServiceProvider services)
{
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(),
adminStore ?? new FakeApiKeyAdminStore(),
auditStore ?? new FakeApiKeyAuditStore(),
hasher ?? new FakeApiKeySecretHasher(),
services.GetRequiredService<ApiKeyAdminCommands>(),
services.GetRequiredService<IApiKeyAdminStore>(),
services.GetRequiredService<IApiKeyAuditStore>(),
new HttpContextAccessor { HttpContext = httpContext });
}
private ServiceProvider BuildServices()
{
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
_tempDirectories.Add(directory);
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(
new Dictionary<string, string?>
{
["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
["MxGateway:ApiKeyPepper"] = "test-pepper"
})
.Build();
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore(configuration);
ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
// Production migrates the schema via the migration hosted service at startup; in these
// DI-only tests no host runs, so apply the (idempotent) migration up front.
provider.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>()
.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
return provider;
}
private static Task<IReadOnlyList<ApiKeyListItem>> ListAsync(ServiceProvider services)
{
return services.GetRequiredService<IApiKeyAdminStore>().ListAsync(CancellationToken.None);
}
private static Task<IReadOnlyList<ApiKeyAuditEntry>> ListAuditAsync(ServiceProvider services)
{
return services.GetRequiredService<IApiKeyAuditStore>().ListRecentAsync(50, CancellationToken.None);
}
private static DashboardApiKeyManagementRequest CreateRequest()
{
return new DashboardApiKeyManagementRequest(
@@ -275,114 +316,14 @@ public sealed class DashboardApiKeyManagementServiceTests
return new ClaimsPrincipal(identity);
}
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
public void Dispose()
{
/// <summary>Gets the count of create operations performed.</summary>
public int CreateCount { get; private set; }
/// <summary>Gets the count of revoke operations performed.</summary>
public int RevokeCount { get; private set; }
/// <summary>Gets the count of delete operations performed.</summary>
public int DeleteCount { get; private set; }
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
public bool RevokeResult { get; init; }
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
public bool RotateResult { get; init; }
/// <summary>Gets or sets the result value returned by delete operations.</summary>
public bool DeleteResult { get; init; }
/// <summary>Gets the last key ID revoked.</summary>
public string? LastRevokedKeyId { get; private set; }
/// <summary>Gets the last key ID deleted.</summary>
public string? LastDeletedKeyId { get; private set; }
/// <summary>Gets the last secret hash rotated.</summary>
public byte[]? LastRotatedSecretHash { get; private set; }
/// <summary>Gets the list of create requests received.</summary>
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
foreach (TempDatabaseDirectory directory in _tempDirectories)
{
CreateCount++;
CreatedRequests.Add(request);
return Task.CompletedTask;
directory.Dispose();
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
CancellationToken cancellationToken)
{
RevokeCount++;
LastRevokedKeyId = keyId;
return Task.FromResult(RevokeResult);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
{
LastRotatedSecretHash = secretHash;
return Task.FromResult(RotateResult);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
DeleteCount++;
LastDeletedKeyId = keyId;
return Task.FromResult(DeleteResult);
}
}
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the list of audit entries appended.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
}
}
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{
/// <summary>Gets the last secret hashed.</summary>
public string? LastSecret { get; private set; }
/// <inheritdoc />
public byte[] HashSecret(string secret)
{
LastSecret = secret;
return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
}
_tempDirectories.Clear();
}
}
@@ -1,5 +1,6 @@
using System.Globalization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -273,16 +274,15 @@ public sealed class DashboardSnapshotServiceTests
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
new ApiKeyListItem(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
KeyPrefix: "mxgw",
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with
ConstraintsJson: ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
},
}),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
@@ -310,13 +310,12 @@ public sealed class DashboardSnapshotServiceTests
{
using GatewayMetrics metrics = new();
SequencedApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
new ApiKeyListItem(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
KeyPrefix: "mxgw",
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
ConstraintsJson: null,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
@@ -425,63 +424,56 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
public Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public virtual Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
CancellationToken cancellationToken)
public Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
public Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
{
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
public Task<bool> DeleteAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(false);
}
}
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore
{
/// <summary>Gets the count of list operations performed.</summary>
public int ListCount { get; protected set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
ListCount++;
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>(records);
}
}
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record)
{
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
public bool FailNext { get; set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
if (FailNext)
{
@@ -490,7 +482,7 @@ public sealed class DashboardSnapshotServiceTests
throw new InvalidOperationException("Simulated SQLite failure.");
}
return base.ListAsync(cancellationToken);
return base.ListAsync(ct);
}
}