b5da1c8c57
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
341 lines
14 KiB
C#
341 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Live contract tests for <see cref="SqlServerApiKeyStore"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A one-for-one port of <c>SqliteApiKeyStoreTests</c> (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.
|
|
/// </remarks>
|
|
[Collection("live-sqlserver")]
|
|
public sealed class SqlServerApiKeyStoreLiveTests : IAsyncLifetime
|
|
{
|
|
private readonly string _schema = LiveSqlServer.NewSchemaName();
|
|
|
|
private AuthSqlServerConnectionFactory _factory = null!;
|
|
private SqlServerApiKeyStore _store = null!;
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<ArgumentException>(
|
|
() => _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<ArgumentException>(
|
|
() => _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<ArgumentException>(
|
|
() => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
|
|
}
|
|
|
|
[SkippableFact]
|
|
public void ScopeSerializer_RoundTripsAndSortsOrdinally()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
var unsorted = new HashSet<string>(["zeta", "alpha", "mike"], StringComparer.Ordinal);
|
|
var differentOrder = new HashSet<string>(["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<string> 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<string> 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<string>(["read", "write"], StringComparer.Ordinal),
|
|
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
|
|
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
|
|
LastUsedUtc: null,
|
|
RevokedUtc: null);
|
|
|
|
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);
|
|
}
|
|
}
|