240 lines
8.6 KiB
C#
240 lines
8.6 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
|
|
|
public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
|
|
{
|
|
private readonly string _dbPath =
|
|
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
|
|
|
|
private AuthSqliteConnectionFactory _factory = null!;
|
|
private SqliteApiKeyStore _store = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_factory = new AuthSqliteConnectionFactory(_dbPath);
|
|
await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
|
_store = new SqliteApiKeyStore(_factory);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindByKeyId_AfterInsert_ReturnsEqualRecord()
|
|
{
|
|
ApiKeyRecord record = SampleRecord("key-1");
|
|
await InsertAsync(record);
|
|
|
|
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-1", CancellationToken.None);
|
|
|
|
Assert.NotNull(found);
|
|
AssertRecordEqual(record, found!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindByKeyId_ReturnsRevokedRecord()
|
|
{
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindActiveByKeyId_RevokedKey_ReturnsNull()
|
|
{
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindActiveByKeyId_ActiveKey_ReturnsRecord()
|
|
{
|
|
await InsertAsync(SampleRecord("key-active"));
|
|
|
|
ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-active", CancellationToken.None);
|
|
|
|
Assert.NotNull(found);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindByKeyId_UnknownKey_ReturnsNull()
|
|
{
|
|
ApiKeyRecord? found = await _store.FindByKeyIdAsync("missing", CancellationToken.None);
|
|
|
|
Assert.Null(found);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MarkUsed_ActiveKey_UpdatesLastUsed()
|
|
{
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MarkUsed_RevokedKey_DoesNotUpdateLastUsed()
|
|
{
|
|
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);
|
|
}
|
|
|
|
// --- keyId guard tests ---
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task FindByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
// 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));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
|
{
|
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
|
() => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public void ScopeSerializer_RoundTripsAndSortsOrdinally()
|
|
{
|
|
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));
|
|
}
|
|
|
|
[Fact]
|
|
public void ScopeSerializer_DeserializeNullOrEmpty_ReturnsEmptySet()
|
|
{
|
|
Assert.Empty(ScopeSerializer.Deserialize(null));
|
|
Assert.Empty(ScopeSerializer.Deserialize(""));
|
|
}
|
|
|
|
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 SqliteConnection connection =
|
|
await _factory.OpenConnectionAsync(CancellationToken.None);
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText = """
|
|
INSERT INTO api_keys (
|
|
key_id, key_prefix, secret_hash, display_name, scopes,
|
|
constraints, created_utc, last_used_utc, revoked_utc)
|
|
VALUES (
|
|
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
|
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
|
""";
|
|
command.Parameters.AddWithValue("$key_id", record.KeyId);
|
|
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
|
|
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
|
|
command.Parameters.AddWithValue("$display_name", record.DisplayName);
|
|
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
|
|
command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
|
|
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
|
|
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
TryDelete(_dbPath);
|
|
TryDelete(_dbPath + "-wal");
|
|
TryDelete(_dbPath + "-shm");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static void TryDelete(string path)
|
|
{
|
|
try
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
catch (IOException)
|
|
{
|
|
// Best-effort cleanup of the per-test temp database.
|
|
}
|
|
}
|
|
}
|