dcee4199ab
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
284 lines
12 KiB
C#
284 lines
12 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.SqlServer.Tests.Live;
|
|
|
|
/// <summary>
|
|
/// Live tests for <see cref="SqliteApiKeyStoreImporter"/> — the one-shot per-node SQLite to shared
|
|
/// SQL Server copy.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each test owns a throwaway SQL Server schema AND a throwaway SQLite file under the temp
|
|
/// directory; both are torn down in <see cref="DisposeAsync"/>, so a failed run leaves nothing
|
|
/// behind on a shared database or the developer's disk.
|
|
/// </remarks>
|
|
[Collection("live-sqlserver")]
|
|
public sealed class SqliteApiKeyStoreImporterLiveTests : IAsyncLifetime
|
|
{
|
|
private readonly string _schema = LiveSqlServer.NewSchemaName();
|
|
private readonly string _sqlitePath =
|
|
Path.Combine(Path.GetTempPath(), $"zbauth-import-{Guid.NewGuid():N}.db");
|
|
|
|
private AuthSqlServerConnectionFactory _factory = null!;
|
|
private SqlServerApiKeyAdminStore _sqlAdmin = null!;
|
|
private SqlServerApiKeyStore _sqlRead = null!;
|
|
private SqlServerApiKeyAuditStore _sqlAudit = null!;
|
|
private SqliteApiKeyStoreImporter _importer = null!;
|
|
|
|
/// <inheritdoc />
|
|
public async Task InitializeAsync()
|
|
{
|
|
if (!LiveSqlServer.IsEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
|
|
await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
|
_sqlAdmin = new SqlServerApiKeyAdminStore(_factory);
|
|
_sqlRead = new SqlServerApiKeyStore(_factory);
|
|
_sqlAudit = new SqlServerApiKeyAuditStore(_factory);
|
|
_importer = new SqliteApiKeyStoreImporter(_factory);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DisposeAsync()
|
|
{
|
|
// SQLite pools connections by connection string; the handle must be released before the
|
|
// file can be removed cleanly.
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" })
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
await LiveSqlServer.DropSchemaAsync(_schema);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Import_CopiesKeysAndAuditRows_AndReturnsCounts()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
(ApiKeyRecord keyA, ApiKeyRecord keyB) = await SeedSqliteAsync();
|
|
|
|
ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
|
|
|
|
Assert.True(result.SourceFound);
|
|
Assert.Equal(2, result.KeysInserted);
|
|
Assert.Equal(0, result.KeysSkipped);
|
|
Assert.Equal(3, result.AuditInserted);
|
|
Assert.Equal(0, result.AuditSkipped);
|
|
|
|
await AssertRecordMatchesAsync(keyA);
|
|
await AssertRecordMatchesAsync(keyB);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> audit = await _sqlAudit.ListRecentAsync(10, CancellationToken.None);
|
|
Assert.Equal(3, audit.Count);
|
|
Assert.Contains(audit, e => e.KeyId is null && e.EventType == "auth_failed");
|
|
Assert.Contains(audit, e => e.KeyId == "key-a" && e.EventType == "created");
|
|
Assert.Contains(audit, e => e.KeyId == "key-b" && e.EventType == "created");
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Import_Twice_IsIdempotent()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await SeedSqliteAsync();
|
|
|
|
await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
|
|
ApiKeyImportResult second = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
|
|
|
|
Assert.True(second.SourceFound);
|
|
Assert.Equal(0, second.KeysInserted);
|
|
Assert.Equal(2, second.KeysSkipped);
|
|
Assert.Equal(0, second.AuditInserted);
|
|
Assert.Equal(3, second.AuditSkipped);
|
|
|
|
Assert.Equal(2, (await _sqlAdmin.ListAsync(CancellationToken.None)).Count);
|
|
Assert.Equal(3, (await _sqlAudit.ListRecentAsync(10, CancellationToken.None)).Count);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Import_UnionsWithExistingRows()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
var lastUsed = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero);
|
|
ApiKeyRecord keyC = SampleRecord("key-c") with { LastUsedUtc = lastUsed };
|
|
await _sqlAdmin.CreateAsync(keyC, CancellationToken.None);
|
|
|
|
await SeedSqliteAsync();
|
|
|
|
ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
|
|
|
|
Assert.Equal(2, result.KeysInserted);
|
|
Assert.Equal(0, result.KeysSkipped);
|
|
|
|
IReadOnlyList<ApiKeyListItem> listed = await _sqlAdmin.ListAsync(CancellationToken.None);
|
|
Assert.Equal(
|
|
["key-a", "key-b", "key-c"],
|
|
listed.Select(k => k.KeyId).OrderBy(k => k, StringComparer.Ordinal));
|
|
|
|
// The pre-existing row is untouched — the importer only ever inserts.
|
|
Assert.Equal(lastUsed, Assert.Single(listed, k => k.KeyId == "key-c").LastUsedUtc);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Import_MissingFile_ReturnsZeroWithoutThrowing()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
ApiKeyImportResult result = await _importer.ImportAsync(
|
|
Path.Combine(Path.GetTempPath(), $"zbauth-absent-{Guid.NewGuid():N}.db"),
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.SourceFound);
|
|
Assert.Equal(0, result.KeysInserted);
|
|
Assert.Equal(0, result.KeysSkipped);
|
|
Assert.Equal(0, result.AuditInserted);
|
|
Assert.Equal(0, result.AuditSkipped);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Import_PreV3FileWithoutExpiresColumn_ReadsExpiryAsNull()
|
|
{
|
|
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
|
|
|
await CreatePreV3SqliteFileAsync();
|
|
|
|
ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
|
|
|
|
Assert.True(result.SourceFound);
|
|
Assert.Equal(1, result.KeysInserted);
|
|
|
|
ApiKeyRecord? found = await _sqlRead.FindByKeyIdAsync("key-old", CancellationToken.None);
|
|
Assert.NotNull(found);
|
|
Assert.Null(found!.ExpiresUtc);
|
|
Assert.Equal("legacy key", found.DisplayName);
|
|
Assert.Equal(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), found.CreatedUtc);
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
/// <summary>
|
|
/// Seeds the throwaway SQLite file with two keys (one revoked, one with an expiry) and three
|
|
/// audit rows (one of them key-less), then returns the two key records as SQLite holds them.
|
|
/// </summary>
|
|
private async Task<(ApiKeyRecord KeyA, ApiKeyRecord KeyB)> SeedSqliteAsync()
|
|
{
|
|
var sqliteFactory = new AuthSqliteConnectionFactory(_sqlitePath);
|
|
await new SqliteAuthStoreMigrator(sqliteFactory).MigrateAsync(CancellationToken.None);
|
|
|
|
var admin = new SqliteApiKeyAdminStore(sqliteFactory);
|
|
var audit = new SqliteApiKeyAuditStore(sqliteFactory);
|
|
var read = new SqliteApiKeyStore(sqliteFactory);
|
|
|
|
ApiKeyRecord keyA = SampleRecord("key-a");
|
|
ApiKeyRecord keyB = SampleRecord("key-b") with
|
|
{
|
|
ExpiresUtc = new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero),
|
|
};
|
|
|
|
await admin.CreateAsync(keyA, CancellationToken.None);
|
|
await admin.CreateAsync(keyB, CancellationToken.None);
|
|
await admin.RevokeAsync("key-a", new DateTimeOffset(2026, 6, 1, 7, 8, 9, TimeSpan.Zero), CancellationToken.None);
|
|
|
|
await audit.AppendAsync(
|
|
new ApiKeyAuditEntry("key-a", "created", "10.0.0.1", new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero), "by admin"),
|
|
CancellationToken.None);
|
|
await audit.AppendAsync(
|
|
new ApiKeyAuditEntry("key-b", "created", null, new DateTimeOffset(2026, 5, 1, 8, 31, 0, TimeSpan.Zero), null),
|
|
CancellationToken.None);
|
|
await audit.AppendAsync(
|
|
new ApiKeyAuditEntry(null, "auth_failed", "10.0.0.9", new DateTimeOffset(2026, 5, 2, 9, 0, 0, TimeSpan.Zero), "unknown key"),
|
|
CancellationToken.None);
|
|
|
|
// Re-read so the assertions compare against exactly what SQLite holds, revocation included.
|
|
ApiKeyRecord storedA = (await read.FindByKeyIdAsync("key-a", CancellationToken.None))!;
|
|
ApiKeyRecord storedB = (await read.FindByKeyIdAsync("key-b", CancellationToken.None))!;
|
|
|
|
return (storedA, storedB);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a v2-shaped SQLite file by hand — <c>api_keys</c> WITHOUT <c>expires_utc</c> — and
|
|
/// inserts one key row, reproducing a store created before schema v3.
|
|
/// </summary>
|
|
private async Task CreatePreV3SqliteFileAsync()
|
|
{
|
|
await using var connection = new SqliteConnection(
|
|
new SqliteConnectionStringBuilder { DataSource = _sqlitePath }.ToString());
|
|
await connection.OpenAsync();
|
|
|
|
await using SqliteCommand ddl = connection.CreateCommand();
|
|
ddl.CommandText = """
|
|
CREATE TABLE api_keys (
|
|
key_id TEXT PRIMARY KEY,
|
|
key_prefix TEXT NOT NULL,
|
|
secret_hash BLOB NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
scopes TEXT NOT NULL,
|
|
constraints TEXT NULL,
|
|
created_utc TEXT NOT NULL,
|
|
last_used_utc TEXT NULL,
|
|
revoked_utc TEXT NULL
|
|
);
|
|
CREATE TABLE api_key_audit (
|
|
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key_id TEXT NULL,
|
|
event_type TEXT NOT NULL,
|
|
remote_address TEXT NULL,
|
|
created_utc TEXT NOT NULL,
|
|
details TEXT NULL
|
|
);
|
|
""";
|
|
await ddl.ExecuteNonQueryAsync();
|
|
|
|
await using SqliteCommand insert = connection.CreateCommand();
|
|
insert.CommandText = """
|
|
INSERT INTO api_keys (
|
|
key_id, key_prefix, secret_hash, display_name, scopes,
|
|
constraints, created_utc, last_used_utc, revoked_utc)
|
|
VALUES ('key-old', 'sbk_old1', $hash, 'legacy key', 'read', NULL, $created, NULL, NULL);
|
|
""";
|
|
insert.Parameters.Add("$hash", SqliteType.Blob).Value = new byte[] { 4, 5, 6 };
|
|
insert.Parameters.AddWithValue(
|
|
"$created", new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero).ToString("O"));
|
|
await insert.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
private async Task AssertRecordMatchesAsync(ApiKeyRecord expected)
|
|
{
|
|
ApiKeyRecord? actual = await _sqlRead.FindByKeyIdAsync(expected.KeyId, CancellationToken.None);
|
|
|
|
Assert.NotNull(actual);
|
|
Assert.Equal(expected.KeyPrefix, actual!.KeyPrefix);
|
|
Assert.True(expected.SecretHash.SequenceEqual(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);
|
|
Assert.Equal(expected.ExpiresUtc, actual.ExpiresUtc);
|
|
}
|
|
|
|
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
|
KeyId: keyId,
|
|
KeyPrefix: "sbk_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);
|
|
}
|