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