From dcee4199abb32fdc9eb8e558bf73fb4f30055602 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 2 Sep 2026 14:35:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth.apikeys.sqlserver):=20SqliteApiKeySto?= =?UTF-8?q?reImporter=20=E2=80=94=20idempotent=20insert-where-not-exists?= =?UTF-8?q?=20copy=20of=20keys=20+=20audit=20(union=20across=20a=20pair)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB --- .../SqliteApiKeyStoreImporter.cs | 300 ++++++++++++++++++ .../SqliteApiKeyStoreImporterLiveTests.cs | 283 +++++++++++++++++ 2 files changed, 583 insertions(+) create mode 100644 ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs new file mode 100644 index 0000000..59c1ee8 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs @@ -0,0 +1,300 @@ +using System.Data; +using Microsoft.Data.SqlClient; +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; + +/// Result of a one-shot import. +/// Keys copied into SQL Server by this run. +/// Keys whose key_id already existed in SQL Server. +/// Audit rows copied into SQL Server by this run. +/// Audit rows that already existed in SQL Server. +/// Whether the SQLite file existed at all. +public sealed record ApiKeyImportResult( + int KeysInserted, int KeysSkipped, int AuditInserted, int AuditSkipped, bool SourceFound); + +/// +/// Copies every key and audit row from a per-node SQLite store into the shared SQL Server store, +/// insert-where-not-exists (keys by key_id; audit rows by key_id + event_type + created_utc), so it is +/// idempotent and every node of a pair may run it: the result is the UNION of the nodes' files. A row +/// that already exists in SQL Server is never modified — a revoke recorded on one node but not the +/// other therefore survives only if the revoked copy is imported first; operators reconcile by listing. +/// +/// Factory for connections to the shared SQL Server API-key database. +public sealed class SqliteApiKeyStoreImporter(AuthSqlServerConnectionFactory sql) +{ + /// + /// Imports the keys and audit rows held in the SQLite store at . + /// + /// Path of the per-node SQLite API-key file. + /// A token to cancel the import. + /// + /// The per-category insert/skip counts. When the file does not exist the result is all zeroes + /// with and nothing is + /// opened — a host that has already imported (and renamed the file) pays nothing. + /// + /// + /// + /// The SQLite file is opened READ-ONLY and the schema migrator is deliberately NOT run against + /// it: the source is evidence, not something to upgrade. A file with no api_keys table is + /// treated as an empty source rather than an error, and a pre-v3 file (no expires_utc + /// column) reads its expiry as NULL = never expires, which is exactly what those keys meant. + /// + /// + /// Every row is read and the SQLite connection closed BEFORE any SQL Server write, and all the + /// writes then run inside ONE transaction. So the import is all-or-nothing: a failure part way + /// through rolls the transaction back and the exception propagates to the caller, leaving the + /// shared store exactly as it was. The host decides what that means — for a boot-time import, + /// logging and carrying on is usually right, because keys already in SQL Server keep working. + /// + /// + /// A SQL Server write failed; nothing was committed. + /// The source file could not be read. + public async Task ImportAsync(string sqlitePath, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath); + + if (!File.Exists(sqlitePath)) + { + return new ApiKeyImportResult(0, 0, 0, 0, SourceFound: false); + } + + (IReadOnlyList keys, IReadOnlyList auditEntries) = + await ReadSourceAsync(sqlitePath, ct).ConfigureAwait(false); + + return await WriteAsync(keys, auditEntries, ct).ConfigureAwait(false); + } + + // --- read side (SQLite) --- + + private static async Task<(IReadOnlyList Keys, IReadOnlyList Audit)> + ReadSourceAsync(string sqlitePath, CancellationToken ct) + { + SqliteConnectionStringBuilder builder = new() + { + DataSource = sqlitePath, + Mode = SqliteOpenMode.ReadOnly, + }; + + await using var connection = new SqliteConnection(builder.ToString()); + await connection.OpenAsync(ct).ConfigureAwait(false); + + IReadOnlyList keys = + await TableExistsAsync(connection, SqliteAuthSchema.ApiKeysTable, ct).ConfigureAwait(false) + ? await ReadKeysAsync(connection, ct).ConfigureAwait(false) + : []; + + IReadOnlyList audit = + await TableExistsAsync(connection, SqliteAuthSchema.ApiKeyAuditTable, ct).ConfigureAwait(false) + ? await ReadAuditAsync(connection, ct).ConfigureAwait(false) + : []; + + return (keys, audit); + } + + private static async Task TableExistsAsync( + SqliteConnection connection, string tableName, CancellationToken ct) + { + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $table_name; + """; + command.Parameters.AddWithValue("$table_name", tableName); + + return (long)(await command.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L) > 0; + } + + private static async Task> ReadKeysAsync( + SqliteConnection connection, CancellationToken ct) + { + // A pre-v3 file has no expires_utc column, so select a literal NULL in its place rather than + // failing the import: NULL is precisely what those keys mean (never expires). + bool hasExpires = await ColumnExistsAsync( + connection, SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn, ct).ConfigureAwait(false); + + string expiresColumn = hasExpires ? SqliteAuthSchema.ExpiresUtcColumn : "NULL"; + + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + "SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, " + + $"created_utc, last_used_utc, revoked_utc, {expiresColumn} FROM api_keys;"; + + List keys = []; + + await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + keys.Add(new ApiKeyRecord( + KeyId: reader.GetString(0), + KeyPrefix: reader.GetString(1), + SecretHash: reader.GetFieldValue(2), + DisplayName: reader.GetString(3), + Scopes: ScopeSerializer.Deserialize(reader.GetString(4)), + ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5), + CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)), + LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7), + RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8), + ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9))); + } + + return keys; + } + + // SQLite has no information_schema; PRAGMA table_info is the column probe (the same one + // SqliteAuthStoreMigrator uses to decide whether its v3 ALTER TABLE is needed). + private static async Task ColumnExistsAsync( + SqliteConnection connection, string tableName, string columnName, CancellationToken ct) + { + await using SqliteCommand command = connection.CreateCommand(); + // table_info takes an identifier, not a bindable parameter; the name is an internal constant. + command.CommandText = $"PRAGMA table_info({tableName});"; + + await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + // PRAGMA table_info columns: cid(0), name(1), type(2), ... + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static async Task> ReadAuditAsync( + SqliteConnection connection, CancellationToken ct) + { + await using SqliteCommand command = connection.CreateCommand(); + // Insertion order, so the shared table's IDENTITY re-creates the source's ordering. + command.CommandText = """ + SELECT key_id, event_type, remote_address, created_utc, details + FROM api_key_audit + ORDER BY audit_id; + """; + + List entries = []; + + await using SqliteDataReader 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: SqliteValueParsing.ParseUtc(reader.GetString(3)), + Details: reader.IsDBNull(4) ? null : reader.GetString(4))); + } + + return entries; + } + + // --- write side (SQL Server) --- + + private async Task WriteAsync( + IReadOnlyList keys, + IReadOnlyList auditEntries, + CancellationToken ct) + { + string schema = sql.Schema; + + await using SqlConnection connection = await sql.OpenConnectionAsync(ct).ConfigureAwait(false); + await using SqlTransaction transaction = + (SqlTransaction)await connection.BeginTransactionAsync(ct).ConfigureAwait(false); + + int keysInserted = 0; + int auditInserted = 0; + + foreach (ApiKeyRecord key in keys) + { + if (await InsertKeyIfAbsentAsync(connection, transaction, schema, key, ct).ConfigureAwait(false)) + { + keysInserted++; + } + } + + foreach (ApiKeyAuditEntry entry in auditEntries) + { + if (await InsertAuditIfAbsentAsync(connection, transaction, schema, entry, ct).ConfigureAwait(false)) + { + auditInserted++; + } + } + + await transaction.CommitAsync(ct).ConfigureAwait(false); + + return new ApiKeyImportResult( + KeysInserted: keysInserted, + KeysSkipped: keys.Count - keysInserted, + AuditInserted: auditInserted, + AuditSkipped: auditEntries.Count - auditInserted, + SourceFound: true); + } + + // Returns true when this call inserted the row. The guard is inside the statement rather than a + // separate SELECT so the check and the insert cannot straddle a concurrent writer. + private async Task InsertKeyIfAbsentAsync( + SqlConnection connection, + SqlTransaction transaction, + string schema, + ApiKeyRecord key, + CancellationToken ct) + { + await using SqlCommand command = sql.CreateCommand(connection, $""" + IF NOT EXISTS ( + SELECT 1 FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] WHERE key_id = @key_id) + 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); + """, transaction); + command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = key.KeyId; + command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = key.KeyPrefix; + command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = key.SecretHash; + command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = key.DisplayName; + command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(key.Scopes); + command.Parameters.Add("@constraints", SqlDbType.NVarChar, -1).Value = (object?)key.ConstraintsJson ?? DBNull.Value; + command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = key.CreatedUtc; + command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = (object?)key.LastUsedUtc ?? DBNull.Value; + command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = (object?)key.RevokedUtc ?? DBNull.Value; + command.Parameters.Add("@expires_utc", SqlDbType.DateTimeOffset).Value = (object?)key.ExpiresUtc ?? DBNull.Value; + + return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false) > 0; + } + + // Audit rows carry no natural key (audit_id is per-file IDENTITY), so identity is the triple + // key_id + event_type + created_utc. key_id is nullable, hence the explicit NULL-safe arm: a + // plain `key_id = @key_id` is UNKNOWN, never true, when both sides are NULL. + private async Task InsertAuditIfAbsentAsync( + SqlConnection connection, + SqlTransaction transaction, + string schema, + ApiKeyAuditEntry entry, + CancellationToken ct) + { + await using SqlCommand command = sql.CreateCommand(connection, $""" + IF NOT EXISTS ( + SELECT 1 FROM [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] + WHERE ((key_id = @key_id) OR (key_id IS NULL AND @key_id IS NULL)) + AND event_type = @event_type + AND created_utc = @created_utc) + INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] ( + key_id, event_type, remote_address, created_utc, details) + VALUES (@key_id, @event_type, @remote_address, @created_utc, @details); + """, transaction); + 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; + + return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false) > 0; + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs new file mode 100644 index 0000000..3e6218f --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs @@ -0,0 +1,283 @@ +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; + +/// +/// Live tests for — the one-shot per-node SQLite to shared +/// SQL Server copy. +/// +/// +/// Each test owns a throwaway SQL Server schema AND a throwaway SQLite file under the temp +/// directory; both are torn down in , so a failed run leaves nothing +/// behind on a shared database or the developer's disk. +/// +[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!; + + /// + 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); + } + + /// + 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 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 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 --- + + /// + /// 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. + /// + 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); + } + + /// + /// Writes a v2-shaped SQLite file by hand — api_keys WITHOUT expires_utc — and + /// inserts one key row, reproducing a store created before schema v3. + /// + 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(["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); +}