feat(auth.apikeys.sqlserver): IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over SQL Server, semantics pinned by the SQLite contract tests re-run live
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-backed administration store for API keys (create, revoke, rotate, delete,
|
||||
/// set-scopes, enable/disable, list).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Semantics are a deliberate one-for-one port of <c>SqliteApiKeyAdminStore</c> — 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.
|
||||
/// </remarks>
|
||||
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
|
||||
public sealed class SqlServerApiKeyAdminStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAdminStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A plain INSERT: a duplicate <c>key_id</c> surfaces as a primary-key
|
||||
/// <see cref="SqlException"/> rather than silently overwriting an existing key (the SQLite store
|
||||
/// raises its own <c>SqliteException</c> in the same situation).
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Guarded on <c>revoked_utc IS NULL</c>, so the returned flag means "this call revoked it" —
|
||||
/// a second revoke of the same key returns <see langword="false"/> and preserves the original
|
||||
/// revocation instant.
|
||||
/// </remarks>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Rotating also clears <c>last_used_utc</c> and <c>revoked_utc</c> — a rotated key is a fresh,
|
||||
/// active credential — while leaving <c>expires_utc</c> alone, so an absolute expiry survives a
|
||||
/// rotation.
|
||||
/// </remarks>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Only a REVOKED key can be deleted (<c>revoked_utc IS NOT NULL</c>), so an active credential
|
||||
/// cannot vanish in one step — revoke first, and the revocation stands on its own until then.
|
||||
/// </remarks>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyListItem>> 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<ApiKeyListItem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-backed, append-only audit store for API-key events. A one-for-one port of
|
||||
/// <c>SqliteApiKeyAuditStore</c>; 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.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
|
||||
public sealed class SqlServerApiKeyAuditStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAuditStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Ordered by the IDENTITY <c>audit_id</c>, not by <c>created_utc</c>: two events stamped in the
|
||||
/// same instant still come back in insertion order. A non-positive limit short-circuits without
|
||||
/// opening a connection — <c>SELECT TOP (-1)</c> is not even legal T-SQL.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<ApiKeyAuditEntry>> 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<ApiKeyAuditEntry> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-backed read store for API-key records — the verification hot path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Semantics are a deliberate one-for-one port of <c>SqliteApiKeyStore</c>, 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.
|
||||
/// </remarks>
|
||||
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
|
||||
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";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
return FindAsync(keyId, requireActive: false, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
return FindAsync(keyId, requireActive: true, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Guarded on <c>revoked_utc IS NULL</c>: a revoked key's last-used stamp is frozen at its last
|
||||
/// legitimate use, so it never records the rejected attempts that follow.
|
||||
/// </remarks>
|
||||
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<ApiKeyRecord?> 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<byte[]>(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));
|
||||
}
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
|
||||
|
||||
/// <summary>
|
||||
/// Live contract tests for <see cref="SqlServerApiKeyAdminStore"/> (and the audit store it is
|
||||
/// exercised alongside).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A one-for-one port of <c>SqliteApiKeyAdminStoreTests</c> — same test names, same assertions — so
|
||||
/// the create/revoke/rotate/scope/enable/delete semantics are pinned identically across the two
|
||||
/// providers. xUnit constructs a new test-class instance per test, so each test provisions and drops
|
||||
/// its own throwaway schema.
|
||||
/// </remarks>
|
||||
[Collection("live-sqlserver")]
|
||||
public sealed class SqlServerApiKeyAdminStoreLiveTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _schema = LiveSqlServer.NewSchemaName();
|
||||
|
||||
private AuthSqlServerConnectionFactory _factory = null!;
|
||||
private SqlServerApiKeyAdminStore _admin = null!;
|
||||
private SqlServerApiKeyStore _read = null!;
|
||||
private SqlServerApiKeyAuditStore _audit = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (!LiveSqlServer.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
|
||||
await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
||||
_admin = new SqlServerApiKeyAdminStore(_factory);
|
||||
_read = new SqlServerApiKeyStore(_factory);
|
||||
_audit = new SqlServerApiKeyAuditStore(_factory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DisposeAsync() => LiveSqlServer.DropSchemaAsync(_schema);
|
||||
|
||||
// --- Create ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Create_ThenFindByKeyId_ReturnsRecord()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
ApiKeyRecord record = SampleRecord("key-1");
|
||||
|
||||
await _admin.CreateAsync(record, CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(record.SecretHash, found!.SecretHash);
|
||||
Assert.True(record.Scopes.SetEquals(found.Scopes));
|
||||
Assert.Equal(record.ConstraintsJson, found.ConstraintsJson);
|
||||
Assert.Null(found.LastUsedUtc);
|
||||
Assert.Null(found.RevokedUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Create_WithExpiresUtc_RoundTripsThroughFindAndList()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
var expiry = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
ApiKeyRecord record = SampleRecord("key-exp") with { ExpiresUtc = expiry };
|
||||
|
||||
await _admin.CreateAsync(record, CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
|
||||
Assert.Equal(expiry, found!.ExpiresUtc);
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-exp");
|
||||
Assert.Equal(expiry, item.ExpiresUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Create_WithoutExpiresUtc_ReadsBackAsNull()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Null(found!.ExpiresUtc);
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
|
||||
}
|
||||
|
||||
// --- Revoke ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Revoke_ActiveKey_SetsRevokedAndFindActiveReturnsNull()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
bool result = await _admin.RevokeAsync("key-1", when, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(when, found!.RevokedUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Revoke_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
bool result = await _admin.RevokeAsync("missing", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Revoke_AlreadyRevoked_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
bool result = await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- Rotate ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Rotate_ChangesHashAndReactivates()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
await _read.MarkUsedAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
byte[] newHash = [9, 9, 9, 9];
|
||||
|
||||
bool result = await _admin.RotateAsync("key-1", newHash, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(newHash, found!.SecretHash);
|
||||
Assert.Null(found.RevokedUtc);
|
||||
Assert.Null(found.LastUsedUtc);
|
||||
// Reactivated: now visible as active.
|
||||
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Rotate_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
bool result = await _admin.RotateAsync("missing", [1], CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- SetScopes ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(
|
||||
SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
|
||||
CancellationToken.None);
|
||||
|
||||
bool result = await _admin.SetScopesAsync(
|
||||
"key-1",
|
||||
new HashSet<string>(["b", "c"], StringComparer.Ordinal),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
bool result = await _admin.SetScopesAsync(
|
||||
"missing",
|
||||
new HashSet<string>(["b"], StringComparer.Ordinal),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- SetEnabled ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetEnabledAsync_False_DisablesKey()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(when, found!.RevokedUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
ApiKeyRecord original = SampleRecord("key-1");
|
||||
await _admin.CreateAsync(original, CancellationToken.None);
|
||||
// Record some usage so we can prove last_used_utc is left untouched on re-enable.
|
||||
var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
|
||||
await _read.MarkUsedAsync("key-1", used, CancellationToken.None);
|
||||
|
||||
// Disable, then re-enable.
|
||||
await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
// Active again, and the secret hash + last-used timestamp are unchanged.
|
||||
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(active);
|
||||
Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
|
||||
Assert.Null(active.RevokedUtc);
|
||||
Assert.Equal(used, active.LastUsedUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Delete_ActiveKey_ReturnsFalseAndKeyStillPresent()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Delete_RevokedKey_ReturnsTrueAndKeyGone()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Delete_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
bool result = await _admin.DeleteAsync("missing", CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- keyId guard tests ---
|
||||
|
||||
[SkippableTheory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Revoke_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
// ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
|
||||
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Delete_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.SetScopesAsync(
|
||||
keyId!,
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- SetEnabled idempotence ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(active);
|
||||
Assert.Null(active!.RevokedUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Disable at t1.
|
||||
await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);
|
||||
|
||||
// Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
|
||||
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.Equal(t2, item.RevokedUtc);
|
||||
}
|
||||
|
||||
// --- Audit ---
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Audit_AppendThenListRecent_ReturnsEntry()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
var entry = new ApiKeyAuditEntry(
|
||||
KeyId: "key-1",
|
||||
EventType: "created",
|
||||
RemoteAddress: "10.0.0.1",
|
||||
CreatedUtc: new DateTimeOffset(2026, 5, 31, 10, 0, 0, TimeSpan.Zero),
|
||||
Details: "by admin");
|
||||
|
||||
await _audit.AppendAsync(entry, CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Single(recent);
|
||||
Assert.Equal("key-1", recent[0].KeyId);
|
||||
Assert.Equal("created", recent[0].EventType);
|
||||
Assert.Equal("10.0.0.1", recent[0].RemoteAddress);
|
||||
Assert.Equal("by admin", recent[0].Details);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Audit_ListRecent_ReturnsNewestFirst()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", "first", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", "second", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", "third", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
||||
|
||||
Assert.Equal(["third", "second", "first"], recent.Select(e => e.EventType));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Audit_ListRecent_RespectsLimit()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry("k", $"e{i}", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
}
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(2, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, recent.Count);
|
||||
Assert.Equal(["e4", "e3"], recent.Select(e => e.EventType));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Audit_NullableFields_RoundTripAsNull()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _audit.AppendAsync(
|
||||
new ApiKeyAuditEntry(null, "anon", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Single(recent);
|
||||
Assert.Null(recent[0].KeyId);
|
||||
Assert.Null(recent[0].RemoteAddress);
|
||||
Assert.Null(recent[0].Details);
|
||||
}
|
||||
|
||||
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: "mxgw_ab12",
|
||||
SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
DisplayName: "Test Key " + keyId,
|
||||
Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
||||
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
|
||||
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
|
||||
|
||||
/// <summary>
|
||||
/// Live tests for <see cref="SqlServerApiKeyAuditStore"/> that the ported SQLite suite does not
|
||||
/// already cover: newest-first ordering under a limit, and the non-positive-limit short circuit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// xUnit constructs a new test-class instance per test, so each test provisions and drops its own
|
||||
/// throwaway schema.
|
||||
/// </remarks>
|
||||
[Collection("live-sqlserver")]
|
||||
public sealed class SqlServerApiKeyAuditStoreLiveTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _schema = LiveSqlServer.NewSchemaName();
|
||||
|
||||
private SqlServerApiKeyAuditStore _audit = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<ApiKeyAuditEntry> 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));
|
||||
}
|
||||
}
|
||||
+340
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user