diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs
new file mode 100644
index 0000000..d5310e4
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs
@@ -0,0 +1,247 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// SQL Server-backed administration store for API keys (create, revoke, rotate, delete,
+/// set-scopes, enable/disable, list).
+///
+///
+/// Semantics are a deliberate one-for-one port of SqliteApiKeyAdminStore — same WHERE and SET
+/// clauses, same rows-affected return contract — so an admin command behaves identically whichever
+/// provider a host composed. The point of this provider is only WHERE the rows live: one shared row
+/// set for a clustered pair instead of a file per node, so a revoke on either node is a revoke
+/// everywhere.
+///
+/// Factory for connections to the shared API-key database.
+public sealed class SqlServerApiKeyAdminStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAdminStore
+{
+ ///
+ ///
+ /// A plain INSERT: a duplicate key_id surfaces as a primary-key
+ /// rather than silently overwriting an existing key (the SQLite store
+ /// raises its own SqliteException in the same situation).
+ ///
+ public async Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] (
+ key_id, key_prefix, secret_hash, display_name, scopes,
+ constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
+ VALUES (
+ @key_id, @key_prefix, @secret_hash, @display_name, @scopes,
+ @constraints, @created_utc, @last_used_utc, @revoked_utc, @expires_utc);
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = record.KeyId;
+ command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = record.KeyPrefix;
+ command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = record.SecretHash;
+ command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = record.DisplayName;
+ command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(record.Scopes);
+ command.Parameters.Add("@constraints", SqlDbType.NVarChar, -1).Value = (object?)record.ConstraintsJson ?? DBNull.Value;
+ command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = record.CreatedUtc;
+ command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = (object?)record.LastUsedUtc ?? DBNull.Value;
+ command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = (object?)record.RevokedUtc ?? DBNull.Value;
+ command.Parameters.Add("@expires_utc", SqlDbType.DateTimeOffset).Value = (object?)record.ExpiresUtc ?? DBNull.Value;
+
+ await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ /// Guarded on revoked_utc IS NULL, so the returned flag means "this call revoked it" —
+ /// a second revoke of the same key returns and preserves the original
+ /// revocation instant.
+ ///
+ public async Task RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ SET revoked_utc = @revoked_utc
+ WHERE key_id = @key_id AND revoked_utc IS NULL;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = whenUtc;
+
+ int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ return rows > 0;
+ }
+
+ ///
+ ///
+ /// Rotating also clears last_used_utc and revoked_utc — a rotated key is a fresh,
+ /// active credential — while leaving expires_utc alone, so an absolute expiry survives a
+ /// rotation.
+ ///
+ public async Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+ ArgumentNullException.ThrowIfNull(newSecretHash);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ SET secret_hash = @secret_hash,
+ last_used_utc = NULL,
+ revoked_utc = NULL
+ WHERE key_id = @key_id;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = newSecretHash;
+
+ int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ return rows > 0;
+ }
+
+ ///
+ public async Task SetScopesAsync(string keyId, IReadOnlySet scopes, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ SET scopes = @scopes
+ WHERE key_id = @key_id;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(scopes);
+
+ int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ return rows > 0;
+ }
+
+ ///
+ public async Task SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ // Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works
+ // regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched
+ // — that is what distinguishes re-enable from RotateAsync.
+ SqlCommand command;
+
+ if (enabled)
+ {
+ command = connectionFactory.CreateCommand(connection, $"""
+ UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ SET revoked_utc = NULL
+ WHERE key_id = @key_id;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ }
+ else
+ {
+ command = connectionFactory.CreateCommand(connection, $"""
+ UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ SET revoked_utc = @revoked_utc
+ WHERE key_id = @key_id;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = whenUtc;
+ }
+
+ await using (command)
+ {
+ int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ return rows > 0;
+ }
+ }
+
+ ///
+ ///
+ /// Only a REVOKED key can be deleted (revoked_utc IS NOT NULL), so an active credential
+ /// cannot vanish in one step — revoke first, and the revocation stands on its own until then.
+ ///
+ public async Task DeleteAsync(string keyId, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ DELETE FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ WHERE key_id = @key_id AND revoked_utc IS NOT NULL;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+
+ int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ return rows > 0;
+ }
+
+ ///
+ public async Task> ListAsync(CancellationToken ct)
+ {
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ // Deliberately omits secret_hash so listing can never leak secret material. key_id is the
+ // tie-break so keys minted in the same instant still list in a stable order.
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ SELECT key_id, key_prefix, display_name, scopes, constraints,
+ created_utc, last_used_utc, revoked_utc, expires_utc
+ FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ ORDER BY created_utc DESC, key_id DESC;
+ """);
+
+ List items = [];
+
+ await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ items.Add(new ApiKeyListItem(
+ KeyId: reader.GetString(0),
+ KeyPrefix: reader.GetString(1),
+ DisplayName: reader.GetString(2),
+ Scopes: ScopeSerializer.Deserialize(reader.GetString(3)),
+ ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
+ CreatedUtc: reader.GetDateTimeOffset(5),
+ LastUsedUtc: reader.IsDBNull(6) ? null : reader.GetDateTimeOffset(6),
+ RevokedUtc: reader.IsDBNull(7) ? null : reader.GetDateTimeOffset(7),
+ ExpiresUtc: reader.IsDBNull(8) ? null : reader.GetDateTimeOffset(8)));
+ }
+
+ return items;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAuditStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAuditStore.cs
new file mode 100644
index 0000000..ae82e4f
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAuditStore.cs
@@ -0,0 +1,80 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// SQL Server-backed, append-only audit store for API-key events. A one-for-one port of
+/// SqliteApiKeyAuditStore; on a clustered host the rows from every node land in one table,
+/// so the audit trail no longer has to be stitched together per node.
+///
+/// Factory for connections to the shared API-key database.
+public sealed class SqlServerApiKeyAuditStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAuditStore
+{
+ ///
+ public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] (
+ key_id, event_type, remote_address, created_utc, details)
+ VALUES (@key_id, @event_type, @remote_address, @created_utc, @details);
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = (object?)entry.KeyId ?? DBNull.Value;
+ command.Parameters.Add("@event_type", SqlDbType.NVarChar, 64).Value = entry.EventType;
+ command.Parameters.Add("@remote_address", SqlDbType.NVarChar, 64).Value = (object?)entry.RemoteAddress ?? DBNull.Value;
+ command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = entry.CreatedUtc;
+ command.Parameters.Add("@details", SqlDbType.NVarChar, -1).Value = (object?)entry.Details ?? DBNull.Value;
+
+ await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ /// Ordered by the IDENTITY audit_id, not by created_utc: two events stamped in the
+ /// same instant still come back in insertion order. A non-positive limit short-circuits without
+ /// opening a connection — SELECT TOP (-1) is not even legal T-SQL.
+ ///
+ public async Task> ListRecentAsync(int limit, CancellationToken ct)
+ {
+ if (limit <= 0)
+ {
+ return [];
+ }
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ SELECT TOP (@limit) key_id, event_type, remote_address, created_utc, details
+ FROM [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}]
+ ORDER BY audit_id DESC;
+ """);
+ command.Parameters.Add("@limit", SqlDbType.Int).Value = limit;
+
+ List entries = [];
+
+ await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ entries.Add(new ApiKeyAuditEntry(
+ KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
+ EventType: reader.GetString(1),
+ RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
+ CreatedUtc: reader.GetDateTimeOffset(3),
+ Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
+ }
+
+ return entries;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStore.cs
new file mode 100644
index 0000000..bbdef9d
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStore.cs
@@ -0,0 +1,100 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// SQL Server-backed read store for API-key records — the verification hot path.
+///
+///
+/// Semantics are a deliberate one-for-one port of SqliteApiKeyStore, down to the WHERE
+/// clauses, so a host can switch providers without any behaviour changing. One connection per
+/// operation: the shared store is reached over the network and pooling, not a held connection, is
+/// what keeps a central pair's concurrent verifies cheap.
+///
+/// Factory for connections to the shared API-key database.
+public sealed class SqlServerApiKeyStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyStore
+{
+ private const string SelectColumns =
+ "key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc, expires_utc";
+
+ ///
+ public Task FindByKeyIdAsync(string keyId, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+ return FindAsync(keyId, requireActive: false, ct);
+ }
+
+ ///
+ public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+ return FindAsync(keyId, requireActive: true, ct);
+ }
+
+ ///
+ ///
+ /// Guarded on revoked_utc IS NULL: a revoked key's last-used stamp is frozen at its last
+ /// legitimate use, so it never records the rejected attempts that follow.
+ ///
+ public async Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
+ UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
+ SET last_used_utc = @last_used_utc
+ WHERE key_id = @key_id AND revoked_utc IS NULL;
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = whenUtc;
+
+ await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ }
+
+ private async Task FindAsync(string keyId, bool requireActive, CancellationToken ct)
+ {
+ string schema = connectionFactory.Schema;
+ string table = $"[{schema}].[{SqlServerAuthSchema.ApiKeysTable}]";
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ // Active means NOT REVOKED only. Expiry is deliberately not filtered here — the verifier
+ // owns it, so it can tell an expired key from an unknown one (ApiKeyFailure.KeyExpired).
+ await using SqlCommand command = connectionFactory.CreateCommand(
+ connection,
+ requireActive
+ ? $"SELECT {SelectColumns} FROM {table} WHERE key_id = @key_id AND revoked_utc IS NULL;"
+ : $"SELECT {SelectColumns} FROM {table} WHERE key_id = @key_id;");
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+
+ await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ if (!await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ return null;
+ }
+
+ return ReadRecord(reader);
+ }
+
+ internal static ApiKeyRecord ReadRecord(SqlDataReader reader) => new(
+ KeyId: reader.GetString(0),
+ KeyPrefix: reader.GetString(1),
+ SecretHash: reader.GetFieldValue(2),
+ DisplayName: reader.GetString(3),
+ Scopes: ScopeSerializer.Deserialize(reader.GetString(4)),
+ ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
+ CreatedUtc: reader.GetDateTimeOffset(6),
+ LastUsedUtc: reader.IsDBNull(7) ? null : reader.GetDateTimeOffset(7),
+ RevokedUtc: reader.IsDBNull(8) ? null : reader.GetDateTimeOffset(8),
+ ExpiresUtc: reader.IsDBNull(9) ? null : reader.GetDateTimeOffset(9));
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyAdminStoreLiveTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyAdminStoreLiveTests.cs
new file mode 100644
index 0000000..1e162a0
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyAdminStoreLiveTests.cs
@@ -0,0 +1,499 @@
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
+
+///
+/// Live contract tests for (and the audit store it is
+/// exercised alongside).
+///
+///
+/// A one-for-one port of SqliteApiKeyAdminStoreTests — 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.
+///
+[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!;
+
+ ///
+ 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);
+ }
+
+ ///
+ 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 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 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(["a"], StringComparer.Ordinal) },
+ CancellationToken.None);
+
+ bool result = await _admin.SetScopesAsync(
+ "key-1",
+ new HashSet(["b", "c"], StringComparer.Ordinal),
+ CancellationToken.None);
+
+ Assert.True(result);
+ IReadOnlyList listed = await _admin.ListAsync(CancellationToken.None);
+ ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
+ Assert.True(item.Scopes.SetEquals(new HashSet(["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(["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(
+ () => _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(
+ () => _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(
+ () => _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(
+ () => _admin.SetScopesAsync(
+ keyId!,
+ new HashSet(["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(
+ () => _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(
+ () => _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 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 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 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 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 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(["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);
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyAuditStoreLiveTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyAuditStoreLiveTests.cs
new file mode 100644
index 0000000..fb77277
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyAuditStoreLiveTests.cs
@@ -0,0 +1,74 @@
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
+
+///
+/// Live tests for that the ported SQLite suite does not
+/// already cover: newest-first ordering under a limit, and the non-positive-limit short circuit.
+///
+///
+/// xUnit constructs a new test-class instance per test, so each test provisions and drops its own
+/// throwaway schema.
+///
+[Collection("live-sqlserver")]
+public sealed class SqlServerApiKeyAuditStoreLiveTests : IAsyncLifetime
+{
+ private readonly string _schema = LiveSqlServer.NewSchemaName();
+
+ private SqlServerApiKeyAuditStore _audit = null!;
+
+ ///
+ public async Task InitializeAsync()
+ {
+ if (!LiveSqlServer.IsEnabled)
+ {
+ return;
+ }
+
+ var factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
+ await new SqlServerApiKeyStoreMigrator(factory).MigrateAsync(CancellationToken.None);
+ _audit = new SqlServerApiKeyAuditStore(factory);
+ }
+
+ ///
+ public Task DisposeAsync() => LiveSqlServer.DropSchemaAsync(_schema);
+
+ [SkippableFact]
+ public async Task Append_ThenListRecent_ReturnsNewestFirstWithinLimit()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ // Every row carries the SAME created_utc, so the newest-first order can only come from the
+ // IDENTITY audit_id the ORDER BY names — the timestamp cannot accidentally supply it.
+ var when = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero);
+ for (int i = 0; i < 6; i++)
+ {
+ await _audit.AppendAsync(
+ new ApiKeyAuditEntry("k", $"e{i}", "10.0.0.1", when, $"detail {i}"),
+ CancellationToken.None);
+ }
+
+ IReadOnlyList recent = await _audit.ListRecentAsync(3, CancellationToken.None);
+
+ Assert.Equal(3, recent.Count);
+ Assert.Equal(["e5", "e4", "e3"], recent.Select(e => e.EventType));
+ Assert.Equal("detail 5", recent[0].Details);
+ Assert.Equal(when, recent[0].CreatedUtc);
+ }
+
+ [SkippableFact]
+ public async Task ListRecent_NonPositiveLimit_ReturnsEmpty()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await _audit.AppendAsync(
+ new ApiKeyAuditEntry("k", "created", null, DateTimeOffset.UtcNow, null),
+ CancellationToken.None);
+
+ // A non-positive limit short circuits WITHOUT touching the database — SELECT TOP (0) would
+ // be legal but SELECT TOP (-1) is a syntax error, so the guard is load-bearing here in a way
+ // it is not for SQLite's LIMIT.
+ Assert.Empty(await _audit.ListRecentAsync(0, CancellationToken.None));
+ Assert.Empty(await _audit.ListRecentAsync(-5, CancellationToken.None));
+ }
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreLiveTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreLiveTests.cs
new file mode 100644
index 0000000..9b5e5fa
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreLiveTests.cs
@@ -0,0 +1,340 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
+
+///
+/// Live contract tests for .
+///
+///
+/// A one-for-one port of SqliteApiKeyStoreTests (same test names, same assertions) so the two
+/// providers are pinned to identical semantics: whichever store a host composes, a row means the
+/// same thing. xUnit constructs a new test-class instance per test, so each test provisions and
+/// drops its own throwaway schema — the isolation the SQLite suite gets from a per-test temp file.
+///
+[Collection("live-sqlserver")]
+public sealed class SqlServerApiKeyStoreLiveTests : IAsyncLifetime
+{
+ private readonly string _schema = LiveSqlServer.NewSchemaName();
+
+ private AuthSqlServerConnectionFactory _factory = null!;
+ private SqlServerApiKeyStore _store = null!;
+
+ ///
+ public async Task InitializeAsync()
+ {
+ if (!LiveSqlServer.IsEnabled)
+ {
+ return;
+ }
+
+ _factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
+ await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
+ _store = new SqlServerApiKeyStore(_factory);
+ }
+
+ ///
+ public Task DisposeAsync() => LiveSqlServer.DropSchemaAsync(_schema);
+
+ [SkippableFact]
+ public async Task FindByKeyId_AfterInsert_ReturnsEqualRecord()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ ApiKeyRecord record = SampleRecord("key-1");
+ await InsertAsync(record);
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-1", CancellationToken.None);
+
+ Assert.NotNull(found);
+ AssertRecordEqual(record, found!);
+ }
+
+ [SkippableFact]
+ public async Task FindByKeyId_ReturnsRevokedRecord()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ ApiKeyRecord record = SampleRecord("key-revoked") with
+ {
+ RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
+ };
+ await InsertAsync(record);
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
+
+ Assert.NotNull(found);
+ Assert.NotNull(found!.RevokedUtc);
+ }
+
+ [SkippableFact]
+ public async Task FindActiveByKeyId_RevokedKey_ReturnsNull()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ ApiKeyRecord record = SampleRecord("key-revoked") with
+ {
+ RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
+ };
+ await InsertAsync(record);
+
+ ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-revoked", CancellationToken.None);
+
+ Assert.Null(found);
+ }
+
+ [SkippableFact]
+ public async Task FindActiveByKeyId_ActiveKey_ReturnsRecord()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await InsertAsync(SampleRecord("key-active"));
+
+ ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-active", CancellationToken.None);
+
+ Assert.NotNull(found);
+ }
+
+ [SkippableFact]
+ public async Task FindByKeyId_UnknownKey_ReturnsNull()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("missing", CancellationToken.None);
+
+ Assert.Null(found);
+ }
+
+ [SkippableFact]
+ public async Task MarkUsed_ActiveKey_UpdatesLastUsed()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await InsertAsync(SampleRecord("key-active"));
+ var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
+
+ await _store.MarkUsedAsync("key-active", when, CancellationToken.None);
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-active", CancellationToken.None);
+ Assert.Equal(when, found!.LastUsedUtc);
+ }
+
+ [SkippableFact]
+ public async Task MarkUsed_RevokedKey_DoesNotUpdateLastUsed()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ ApiKeyRecord record = SampleRecord("key-revoked") with
+ {
+ RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
+ };
+ await InsertAsync(record);
+ var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
+
+ await _store.MarkUsedAsync("key-revoked", when, CancellationToken.None);
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
+ Assert.Null(found!.LastUsedUtc);
+ }
+
+ // --- SQL-Server-specific: DATETIMEOFFSET(7) must not truncate to milliseconds ---
+
+ [SkippableFact]
+ public async Task FindByKeyId_RoundTripsDateTimeOffsetToTheTick()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ // Sub-millisecond ticks: SQLite persists round-trip ("O") text and keeps them for free,
+ // whereas SQL Server keeps them only because the columns are DATETIMEOFFSET(7) and the
+ // parameters are bound as SqlDbType.DateTimeOffset. A DATETIME/DATETIME2(3) column or an
+ // untyped parameter would silently round here.
+ var created = new DateTimeOffset(2026, 3, 4, 5, 6, 7, TimeSpan.Zero).AddTicks(1234567);
+ var expires = new DateTimeOffset(2027, 3, 4, 5, 6, 7, TimeSpan.Zero).AddTicks(7654321);
+ ApiKeyRecord record = SampleRecord("key-ticks") with { CreatedUtc = created, ExpiresUtc = expires };
+ await InsertAsync(record);
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-ticks", CancellationToken.None);
+
+ Assert.NotNull(found);
+ Assert.Equal(created, found!.CreatedUtc);
+ Assert.Equal(created.Ticks, found.CreatedUtc.Ticks);
+ Assert.Equal(expires, found.ExpiresUtc);
+ Assert.Equal(expires.Ticks, found.ExpiresUtc!.Value.Ticks);
+ }
+
+ // --- keyId guard tests ---
+
+ [SkippableTheory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task FindByKeyId_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(
+ () => _store.FindByKeyIdAsync(keyId!, CancellationToken.None));
+ }
+
+ [SkippableTheory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await Assert.ThrowsAnyAsync(
+ () => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None));
+ }
+
+ [SkippableTheory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await Assert.ThrowsAnyAsync(
+ () => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
+ }
+
+ [SkippableFact]
+ public void ScopeSerializer_RoundTripsAndSortsOrdinally()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ var unsorted = new HashSet(["zeta", "alpha", "mike"], StringComparer.Ordinal);
+ var differentOrder = new HashSet(["mike", "zeta", "alpha"], StringComparer.Ordinal);
+
+ string a = ScopeSerializer.Serialize(unsorted);
+ string b = ScopeSerializer.Serialize(differentOrder);
+
+ // Equal sets must produce identical column text regardless of insertion order.
+ Assert.Equal(a, b);
+ Assert.Equal("""["alpha","mike","zeta"]""", a);
+
+ IReadOnlySet roundTripped = ScopeSerializer.Deserialize(a);
+ Assert.True(roundTripped.SetEquals(unsorted));
+ }
+
+ [SkippableFact]
+ public void ScopeSerializer_DeserializeNullOrEmpty_ReturnsEmptySet()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ Assert.Empty(ScopeSerializer.Deserialize(null));
+ Assert.Empty(ScopeSerializer.Deserialize(""));
+ }
+
+ // --- Auth-003: corrupt scopes JSON must fail closed (empty set), never throw JsonException ---
+
+ [SkippableTheory]
+ [InlineData("not json at all")]
+ [InlineData("{")]
+ [InlineData("{\"a\":1}")] // valid JSON, but an object, not a string[]
+ [InlineData("42")] // valid JSON, but a number
+ [InlineData("[\"read\",")] // truncated/partial write
+ public void ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow(string value)
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ // A poisoned scopes column (tampering, partial write, format change, buggy writer) must
+ // degrade to a zero-scope set rather than throwing on the verification hot path.
+ IReadOnlySet scopes = ScopeSerializer.Deserialize(value);
+ Assert.Empty(scopes);
+ }
+
+ [SkippableFact]
+ public async Task FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ // Insert a row whose scopes column holds malformed (non-array) JSON, then read it through
+ // the store. The store must NOT propagate a JsonException out of FindByKeyIdAsync (which the
+ // verifier relies on for its "only exception path is cancellation" contract).
+ await InsertWithRawScopesAsync("key-corrupt", scopesJson: "{ this is not valid json");
+
+ ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-corrupt", CancellationToken.None);
+
+ Assert.NotNull(found);
+ Assert.Empty(found!.Scopes);
+ }
+
+ 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(["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);
+
+ private static void AssertRecordEqual(ApiKeyRecord expected, ApiKeyRecord actual)
+ {
+ Assert.Equal(expected.KeyId, actual.KeyId);
+ Assert.Equal(expected.KeyPrefix, actual.KeyPrefix);
+ Assert.Equal(expected.SecretHash, actual.SecretHash);
+ Assert.Equal(expected.DisplayName, actual.DisplayName);
+ Assert.True(expected.Scopes.SetEquals(actual.Scopes));
+ Assert.Equal(expected.ConstraintsJson, actual.ConstraintsJson);
+ Assert.Equal(expected.CreatedUtc, actual.CreatedUtc);
+ Assert.Equal(expected.LastUsedUtc, actual.LastUsedUtc);
+ Assert.Equal(expected.RevokedUtc, actual.RevokedUtc);
+ }
+
+ private async Task InsertAsync(ApiKeyRecord record)
+ {
+ await using SqlConnection connection =
+ await _factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqlCommand command = _factory.CreateCommand(connection, $"""
+ INSERT INTO [{_schema}].[{SqlServerAuthSchema.ApiKeysTable}] (
+ key_id, key_prefix, secret_hash, display_name, scopes,
+ constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
+ VALUES (
+ @key_id, @key_prefix, @secret_hash, @display_name, @scopes,
+ @constraints, @created_utc, @last_used_utc, @revoked_utc, @expires_utc);
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = record.KeyId;
+ command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = record.KeyPrefix;
+ command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = record.SecretHash;
+ command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = record.DisplayName;
+ command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(record.Scopes);
+ command.Parameters.Add("@constraints", SqlDbType.NVarChar, -1).Value = (object?)record.ConstraintsJson ?? DBNull.Value;
+ command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = record.CreatedUtc;
+ command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = (object?)record.LastUsedUtc ?? DBNull.Value;
+ command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = (object?)record.RevokedUtc ?? DBNull.Value;
+ command.Parameters.Add("@expires_utc", SqlDbType.DateTimeOffset).Value = (object?)record.ExpiresUtc ?? DBNull.Value;
+ await command.ExecuteNonQueryAsync(CancellationToken.None);
+ }
+
+ private async Task InsertWithRawScopesAsync(string keyId, string scopesJson)
+ {
+ // Writes the scopes column verbatim (NOT via ScopeSerializer.Serialize) so a malformed
+ // value can be persisted to simulate tampering / a partial or buggy write.
+ await using SqlConnection connection =
+ await _factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqlCommand command = _factory.CreateCommand(connection, $"""
+ INSERT INTO [{_schema}].[{SqlServerAuthSchema.ApiKeysTable}] (
+ key_id, key_prefix, secret_hash, display_name, scopes,
+ constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
+ VALUES (
+ @key_id, @key_prefix, @secret_hash, @display_name, @scopes,
+ NULL, @created_utc, NULL, NULL, NULL);
+ """);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
+ command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = "mxgw";
+ command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = new byte[] { 1, 2, 3 };
+ command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = "Corrupt Key";
+ command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = scopesJson;
+ command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = DateTimeOffset.UnixEpoch;
+ await command.ExecuteNonQueryAsync(CancellationToken.None);
+ }
+}