Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs
T
Joseph Doherty 378880f0ed feat(apikeys): optional ExpiresUtc — create + verifier enforcement + sqlite v3 migration (archreview G-2)
ApiKeyRecord/ApiKeyListItem gain a nullable ExpiresUtc (NULL = never
expires); ApiKeyFailure gains KeyExpired. ApiKeyVerifier rejects a key
whose ExpiresUtc is at-or-before now (inclusive, injected clock), before
the secret comparison. CreateKeyAsync gets an expiresUtc overload; rotate
preserves existing expiry. SQLite schema bumps to v3: a nullable
expires_utc column, added to fresh DBs via CREATE and to existing v1/v2
DBs via an idempotent guarded ALTER — donor v2 gateway-auth.db upgrades
in place, no key invalidated. Version 0.1.3 -> 0.1.4 (not yet published;
nuget push is human-gated). Consumers (HistorianGateway, mxaccessgw) bump
to 0.1.4 to set/enforce expiry. 146 Auth.ApiKeys tests pass.
2026-07-09 06:04:54 -04:00

204 lines
8.2 KiB
C#

using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public sealed class SqliteMigratorTests : IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
private AuthSqliteConnectionFactory Factory => new(_dbPath);
[Fact]
public async Task MigrateAsync_CreatesAllThreeTables()
{
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeyAuditTable));
Assert.True(await TableExistsAsync(SqliteAuthSchema.SchemaVersionTable));
}
[Fact]
public async Task MigrateAsync_RunTwice_IsIdempotentAndRecordsCurrentVersion()
{
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadVersionAsync());
Assert.Equal(1, await CountSchemaVersionRowsAsync());
}
[Fact]
public void CurrentVersion_Is3_ForExpiresUtcColumn() =>
// v3 adds the nullable expires_utc column (archreview G-2). The value must stay >= 2 so the
// migrator does not refuse a donor MxAccessGateway gateway-auth.db (stamped 2) on first boot:
// 2 <= 3, so such a DB is accepted and upgraded in place. Locking this invariant.
Assert.Equal(3, SqliteAuthSchema.CurrentVersion);
[Fact]
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndUpgradesTo3()
{
// The deployed-gateway scenario: a database already provisioned at the donor's version 2.
// Migrating must accept it (2 <= 3), add the expires_utc column, and re-stamp to 3 — no key
// is invalidated, the table survives.
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(2);
await migrator.MigrateAsync(CancellationToken.None); // must not throw
Assert.Equal(3, await ReadVersionAsync());
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
}
[Fact]
public async Task MigrateAsync_PreV3ApiKeysTableWithoutExpiresUtc_AddsColumn_AndPreservesRows()
{
// Simulate a genuine pre-expiry (v2) database: an api_keys table WITHOUT expires_utc, holding
// an existing key. The forward migration must add the column (existing row → NULL) without
// dropping the row.
await CreateLegacyV2SchemaWithKeyAsync("legacy-key");
Assert.False(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
await new SqliteAuthStoreMigrator(Factory).MigrateAsync(CancellationToken.None);
Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
Assert.Equal(3, await ReadVersionAsync());
// The pre-existing key survives and its new expires_utc is NULL (never-expires).
var read = new SqliteApiKeyStore(Factory);
var found = await read.FindByKeyIdAsync("legacy-key", CancellationToken.None);
Assert.NotNull(found);
Assert.Null(found!.ExpiresUtc);
}
private async Task CreateLegacyV2SchemaWithKeyAsync(string keyId)
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.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
);
{SqliteAuthSchema.CreateSchemaVersionTable}
INSERT INTO schema_version (id, version, applied_utc) VALUES (1, 2, '2026-01-01T00:00:00.0000000+00:00');
INSERT INTO api_keys (key_id, key_prefix, secret_hash, display_name, scopes, created_utc)
VALUES ($key_id, 'mxgw', X'01020304', 'Legacy', 'read', '2026-01-01T00:00:00.0000000+00:00');
""";
command.Parameters.AddWithValue("$key_id", keyId);
await command.ExecuteNonQueryAsync(CancellationToken.None);
}
private async Task<bool> ColumnExistsAsync(string table, string column)
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({table});";
await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None);
while (await reader.ReadAsync(CancellationToken.None))
{
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
[Fact]
public async Task MigrateAsync_FutureSchemaVersion_Throws()
{
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(99);
await Assert.ThrowsAsync<AuthStoreMigrationException>(
() => migrator.MigrateAsync(CancellationToken.None));
}
private async Task<bool> TableExistsAsync(string tableName)
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;";
command.Parameters.AddWithValue("$name", tableName);
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
return count == 1;
}
private async Task<int> ReadVersionAsync()
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
object? value = await command.ExecuteScalarAsync(CancellationToken.None);
return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture);
}
private async Task<int> CountSchemaVersionRowsAsync()
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM schema_version;";
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
return (int)count;
}
private async Task SetVersionAsync(int version)
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "UPDATE schema_version SET version = $version WHERE id = 1;";
command.Parameters.AddWithValue("$version", version);
await command.ExecuteNonQueryAsync(CancellationToken.None);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
TryDelete(_dbPath);
TryDelete(_dbPath + "-wal");
TryDelete(_dbPath + "-shm");
}
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.
}
}
}