b5da1c8c57
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
500 lines
19 KiB
C#
500 lines
19 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
|
|
|
|
/// <summary>
|
|
/// Live contract tests for <see cref="SqlServerApiKeyAdminStore"/> (and the audit store it is
|
|
/// exercised alongside).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A one-for-one port of <c>SqliteApiKeyAdminStoreTests</c> — same test names, same assertions — so
|
|
/// the create/revoke/rotate/scope/enable/delete semantics are pinned identically across the two
|
|
/// providers. xUnit constructs a new test-class instance per test, so each test provisions and drops
|
|
/// its own throwaway schema.
|
|
/// </remarks>
|
|
[Collection("live-sqlserver")]
|
|
public sealed class SqlServerApiKeyAdminStoreLiveTests : IAsyncLifetime
|
|
{
|
|
private readonly string _schema = LiveSqlServer.NewSchemaName();
|
|
|
|
private AuthSqlServerConnectionFactory _factory = null!;
|
|
private SqlServerApiKeyAdminStore _admin = null!;
|
|
private SqlServerApiKeyStore _read = null!;
|
|
private SqlServerApiKeyAuditStore _audit = null!;
|
|
|
|
/// <inheritdoc />
|
|
public async Task InitializeAsync()
|
|
{
|
|
if (!LiveSqlServer.IsEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
|
|
await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
|
_admin = new SqlServerApiKeyAdminStore(_factory);
|
|
_read = new SqlServerApiKeyStore(_factory);
|
|
_audit = new SqlServerApiKeyAuditStore(_factory);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DisposeAsync() => LiveSqlServer.DropSchemaAsync(_schema);
|
|
|
|
// --- Create ---
|
|
|
|
[SkippableFact]
|
|
public async Task Create_ThenFindByKeyId_ReturnsRecord()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
ApiKeyRecord record = SampleRecord("key-1");
|
|
|
|
await _admin.CreateAsync(record, CancellationToken.None);
|
|
|
|
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.NotNull(found);
|
|
Assert.Equal(record.SecretHash, found!.SecretHash);
|
|
Assert.True(record.Scopes.SetEquals(found.Scopes));
|
|
Assert.Equal(record.ConstraintsJson, found.ConstraintsJson);
|
|
Assert.Null(found.LastUsedUtc);
|
|
Assert.Null(found.RevokedUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Create_WithExpiresUtc_RoundTripsThroughFindAndList()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
var expiry = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
|
ApiKeyRecord record = SampleRecord("key-exp") with { ExpiresUtc = expiry };
|
|
|
|
await _admin.CreateAsync(record, CancellationToken.None);
|
|
|
|
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
|
|
Assert.Equal(expiry, found!.ExpiresUtc);
|
|
|
|
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
|
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-exp");
|
|
Assert.Equal(expiry, item.ExpiresUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Create_WithoutExpiresUtc_ReadsBackAsNull()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
|
|
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.Null(found!.ExpiresUtc);
|
|
|
|
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
|
Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
|
|
}
|
|
|
|
// --- Revoke ---
|
|
|
|
[SkippableFact]
|
|
public async Task Revoke_ActiveKey_SetsRevokedAndFindActiveReturnsNull()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
|
|
|
bool result = await _admin.RevokeAsync("key-1", when, CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
|
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.Equal(when, found!.RevokedUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Revoke_UnknownKey_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
bool result = await _admin.RevokeAsync("missing", DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Revoke_AlreadyRevoked_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
bool result = await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
// --- Rotate ---
|
|
|
|
[SkippableFact]
|
|
public async Task Rotate_ChangesHashAndReactivates()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
|
await _read.MarkUsedAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
|
byte[] newHash = [9, 9, 9, 9];
|
|
|
|
bool result = await _admin.RotateAsync("key-1", newHash, CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.Equal(newHash, found!.SecretHash);
|
|
Assert.Null(found.RevokedUtc);
|
|
Assert.Null(found.LastUsedUtc);
|
|
// Reactivated: now visible as active.
|
|
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Rotate_UnknownKey_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
bool result = await _admin.RotateAsync("missing", [1], CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
// --- SetScopes ---
|
|
|
|
[SkippableFact]
|
|
public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(
|
|
SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
|
|
CancellationToken.None);
|
|
|
|
bool result = await _admin.SetScopesAsync(
|
|
"key-1",
|
|
new HashSet<string>(["b", "c"], StringComparer.Ordinal),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
|
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
|
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
bool result = await _admin.SetScopesAsync(
|
|
"missing",
|
|
new HashSet<string>(["b"], StringComparer.Ordinal),
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
// --- SetEnabled ---
|
|
|
|
[SkippableFact]
|
|
public async Task SetEnabledAsync_False_DisablesKey()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
|
|
|
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
|
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.Equal(when, found!.RevokedUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
ApiKeyRecord original = SampleRecord("key-1");
|
|
await _admin.CreateAsync(original, CancellationToken.None);
|
|
// Record some usage so we can prove last_used_utc is left untouched on re-enable.
|
|
var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
|
|
await _read.MarkUsedAsync("key-1", used, CancellationToken.None);
|
|
|
|
// Disable, then re-enable.
|
|
await _admin.SetEnabledAsync(
|
|
"key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
|
bool result = await _admin.SetEnabledAsync(
|
|
"key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
|
|
// Active again, and the secret hash + last-used timestamp are unchanged.
|
|
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.NotNull(active);
|
|
Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
|
|
Assert.Null(active.RevokedUtc);
|
|
Assert.Equal(used, active.LastUsedUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
bool result = await _admin.SetEnabledAsync(
|
|
"missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
// --- Delete ---
|
|
|
|
[SkippableFact]
|
|
public async Task Delete_ActiveKey_ReturnsFalseAndKeyStillPresent()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
|
|
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Delete_RevokedKey_ReturnsTrueAndKeyGone()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Delete_UnknownKey_ReturnsFalse()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
bool result = await _admin.DeleteAsync("missing", CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
// --- keyId guard tests ---
|
|
|
|
[SkippableTheory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task Revoke_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
// ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
|
|
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
|
|
}
|
|
|
|
[SkippableTheory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None));
|
|
}
|
|
|
|
[SkippableTheory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task Delete_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
|
|
}
|
|
|
|
[SkippableTheory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _admin.SetScopesAsync(
|
|
keyId!,
|
|
new HashSet<string>(["read"], StringComparer.Ordinal),
|
|
CancellationToken.None));
|
|
}
|
|
|
|
[SkippableTheory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
|
|
await Assert.ThrowsAsync<ArgumentNullException>(
|
|
() => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
|
|
}
|
|
|
|
// --- SetEnabled idempotence ---
|
|
|
|
[SkippableFact]
|
|
public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
|
|
bool result = await _admin.SetEnabledAsync(
|
|
"key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
|
Assert.NotNull(active);
|
|
Assert.Null(active!.RevokedUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
|
var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
|
|
var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
|
|
// Disable at t1.
|
|
await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);
|
|
|
|
// Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
|
|
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);
|
|
|
|
Assert.True(result);
|
|
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
|
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
|
Assert.Equal(t2, item.RevokedUtc);
|
|
}
|
|
|
|
// --- Audit ---
|
|
|
|
[SkippableFact]
|
|
public async Task Audit_AppendThenListRecent_ReturnsEntry()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
var entry = new ApiKeyAuditEntry(
|
|
KeyId: "key-1",
|
|
EventType: "created",
|
|
RemoteAddress: "10.0.0.1",
|
|
CreatedUtc: new DateTimeOffset(2026, 5, 31, 10, 0, 0, TimeSpan.Zero),
|
|
Details: "by admin");
|
|
|
|
await _audit.AppendAsync(entry, CancellationToken.None);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
|
Assert.Single(recent);
|
|
Assert.Equal("key-1", recent[0].KeyId);
|
|
Assert.Equal("created", recent[0].EventType);
|
|
Assert.Equal("10.0.0.1", recent[0].RemoteAddress);
|
|
Assert.Equal("by admin", recent[0].Details);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Audit_ListRecent_ReturnsNewestFirst()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _audit.AppendAsync(
|
|
new ApiKeyAuditEntry("k", "first", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
|
await _audit.AppendAsync(
|
|
new ApiKeyAuditEntry("k", "second", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
|
await _audit.AppendAsync(
|
|
new ApiKeyAuditEntry("k", "third", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
|
|
|
Assert.Equal(["third", "second", "first"], recent.Select(e => e.EventType));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Audit_ListRecent_RespectsLimit()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
await _audit.AppendAsync(
|
|
new ApiKeyAuditEntry("k", $"e{i}", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
|
}
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(2, CancellationToken.None);
|
|
|
|
Assert.Equal(2, recent.Count);
|
|
Assert.Equal(["e4", "e3"], recent.Select(e => e.EventType));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Audit_NullableFields_RoundTripAsNull()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await _audit.AppendAsync(
|
|
new ApiKeyAuditEntry(null, "anon", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
|
Assert.Single(recent);
|
|
Assert.Null(recent[0].KeyId);
|
|
Assert.Null(recent[0].RemoteAddress);
|
|
Assert.Null(recent[0].Details);
|
|
}
|
|
|
|
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
|
KeyId: keyId,
|
|
KeyPrefix: "mxgw_ab12",
|
|
SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
|
|
DisplayName: "Test Key " + keyId,
|
|
Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
|
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
|
|
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
|
|
LastUsedUtc: null,
|
|
RevokedUtc: null);
|
|
}
|