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.
This commit is contained in:
@@ -35,24 +35,93 @@ public sealed class SqliteMigratorTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() =>
|
||||
// The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is
|
||||
// stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse
|
||||
// those existing databases on first boot. Locking this invariant.
|
||||
Assert.Equal(2, SqliteAuthSchema.CurrentVersion);
|
||||
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_AndStaysAt2()
|
||||
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndUpgradesTo3()
|
||||
{
|
||||
// The deployed-gateway scenario: a database already provisioned at version 2.
|
||||
// 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(2, await ReadVersionAsync());
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user