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); + } +}