using Microsoft.AspNetCore.Builder; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MxGateway.Server; using MxGateway.Server.Configuration; using MxGateway.Server.Security.Authentication; namespace MxGateway.Tests.Security.Authentication; /// /// Tests for . /// public sealed class SqliteAuthStoreTests : IDisposable { private readonly List _tempDirectories = []; /// /// Verifies that MigrateAsync initializes the database schema. /// [Fact] public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema() { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); IAuthStoreMigrator migrator = services.GetRequiredService(); await migrator.MigrateAsync(CancellationToken.None); Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadSchemaVersionAsync(databasePath)); Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeysTable)); Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable)); } /// /// Verifies that MigrateAsync migrates and is idempotent. /// [Fact] public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently() { string databasePath = CreateTempDatabasePath(); await CreateVersionZeroDatabaseAsync(databasePath); await using ServiceProvider services = BuildAuthServices(databasePath); IAuthStoreMigrator migrator = services.GetRequiredService(); await migrator.MigrateAsync(CancellationToken.None); await migrator.MigrateAsync(CancellationToken.None); Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadSchemaVersionAsync(databasePath)); Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeysTable)); Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable)); } /// /// Verifies that gateway startup fails with a newer schema version. /// [Fact] public async Task StartAsync_NewerSchemaVersion_BlocksStartup() { string databasePath = CreateTempDatabasePath(); await CreateSchemaVersionDatabaseAsync(databasePath, SqliteAuthSchema.CurrentVersion + 1); await using WebApplication app = GatewayApplication.Build( [ $"--MxGateway:Authentication:SqlitePath={databasePath}", "--urls=http://127.0.0.1:0" ]); AuthStoreMigrationException exception = await Assert.ThrowsAsync( () => app.StartAsync(CancellationToken.None)); Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal); } /// /// Verifies that FindActiveByKeyIdAsync returns an active key. /// [Fact] public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey() { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); await services.GetRequiredService().MigrateAsync(CancellationToken.None); await InsertApiKeyAsync(databasePath, revokedUtc: null); IApiKeyStore store = services.GetRequiredService(); ApiKeyRecord? key = await store.FindActiveByKeyIdAsync("test-key", CancellationToken.None); Assert.NotNull(key); Assert.Equal("test-key", key.KeyId); Assert.Equal("mxgw_test", key.KeyPrefix); Assert.Equal([1, 2, 3, 4], key.SecretHash); Assert.Contains("session:open", key.Scopes); Assert.Null(key.RevokedUtc); } /// /// Verifies that FindActiveByKeyIdAsync returns null for a revoked key. /// [Fact] public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull() { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); await services.GetRequiredService().MigrateAsync(CancellationToken.None); await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow); IApiKeyStore store = services.GetRequiredService(); ApiKeyRecord? activeKey = await store.FindActiveByKeyIdAsync( "test-key", CancellationToken.None); ApiKeyRecord? storedKey = await store.FindByKeyIdAsync("test-key", CancellationToken.None); Assert.Null(activeKey); Assert.NotNull(storedKey); Assert.NotNull(storedKey.RevokedUtc); } /// /// Verifies that the audit store persists audit events. /// [Fact] public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent() { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); await services.GetRequiredService().MigrateAsync(CancellationToken.None); IApiKeyAuditStore auditStore = services.GetRequiredService(); await auditStore.AppendAsync( new ApiKeyAuditEntry( KeyId: "test-key", EventType: "lookup", RemoteAddress: "127.0.0.1", Details: "matched active key"), CancellationToken.None); IReadOnlyList records = await auditStore.ListRecentAsync( 10, CancellationToken.None); ApiKeyAuditRecord record = Assert.Single(records); Assert.Equal("test-key", record.KeyId); Assert.Equal("lookup", record.EventType); Assert.Equal("127.0.0.1", record.RemoteAddress); Assert.Equal("matched active key", record.Details); } /// /// Verifies that opens /// the auth database in WAL journal mode so concurrent readers and writers degrade /// gracefully instead of surfacing SQLITE_BUSY on the request path. /// [Fact] public async Task OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout() { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); AuthSqliteConnectionFactory factory = services.GetRequiredService(); await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand journalModeCommand = connection.CreateCommand(); journalModeCommand.CommandText = "PRAGMA journal_mode;"; string? journalMode = (string?)await journalModeCommand.ExecuteScalarAsync(CancellationToken.None); await using SqliteCommand busyTimeoutCommand = connection.CreateCommand(); busyTimeoutCommand.CommandText = "PRAGMA busy_timeout;"; long busyTimeout = (long)(await busyTimeoutCommand.ExecuteScalarAsync(CancellationToken.None) ?? 0L); Assert.Equal("wal", journalMode, ignoreCase: true); Assert.True(busyTimeout > 0, $"Expected a non-zero busy_timeout but found {busyTimeout}."); } private static ServiceProvider BuildAuthServices(string databasePath) { IConfigurationRoot configuration = new ConfigurationBuilder() .AddInMemoryCollection( new Dictionary { ["MxGateway:Authentication:SqlitePath"] = databasePath }) .Build(); ServiceCollection services = new(); services.AddSingleton(configuration); services.AddGatewayConfiguration(); services.AddSqliteAuthStore(); return services.BuildServiceProvider(validateScopes: true); } /// Clears SQLite pools and deletes every temporary directory created by this test. public void Dispose() { foreach (TempDatabaseDirectory directory in _tempDirectories) { directory.Dispose(); } _tempDirectories.Clear(); } private string CreateTempDatabasePath() { TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-auth-tests"); _tempDirectories.Add(directory); return directory.DatabasePath(); } private static async Task CreateVersionZeroDatabaseAsync(string databasePath) { await using SqliteConnection connection = CreateConnection(databasePath); await connection.OpenAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ CREATE TABLE schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL, applied_utc TEXT NOT NULL ); INSERT INTO schema_version (id, version, applied_utc) VALUES (1, 0, $applied_utc); """; command.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O")); await command.ExecuteNonQueryAsync(CancellationToken.None); } private static async Task CreateSchemaVersionDatabaseAsync(string databasePath, int version) { await using SqliteConnection connection = CreateConnection(databasePath); await connection.OpenAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ CREATE TABLE schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL, applied_utc TEXT NOT NULL ); INSERT INTO schema_version (id, version, applied_utc) VALUES (1, $version, $applied_utc); """; command.Parameters.AddWithValue("$version", version); command.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O")); await command.ExecuteNonQueryAsync(CancellationToken.None); } private static async Task InsertApiKeyAsync(string databasePath, DateTimeOffset? revokedUtc) { await using SqliteConnection connection = CreateConnection(databasePath); await connection.OpenAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO api_keys ( key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc) VALUES ( $key_id, $key_prefix, $secret_hash, $display_name, $scopes, $created_utc, NULL, $revoked_utc); """; command.Parameters.AddWithValue("$key_id", "test-key"); command.Parameters.AddWithValue("$key_prefix", "mxgw_test"); command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3, 4 }; command.Parameters.AddWithValue("$display_name", "Test Key"); command.Parameters.AddWithValue( "$scopes", ApiKeyScopeSerializer.Serialize(new HashSet(StringComparer.Ordinal) { "session:open", "events:read" })); command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O")); command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value); await command.ExecuteNonQueryAsync(CancellationToken.None); } private static async Task ReadSchemaVersionAsync(string databasePath) { await using SqliteConnection connection = CreateConnection(databasePath); await connection.OpenAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT version FROM schema_version WHERE id = 1;"; object? result = await command.ExecuteScalarAsync(CancellationToken.None); return Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture); } private static async Task TableExistsAsync(string databasePath, string tableName) { await using SqliteConnection connection = CreateConnection(databasePath); await connection.OpenAsync(CancellationToken.None); 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); long result = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L); return result == 1; } private static SqliteConnection CreateConnection(string databasePath) { SqliteConnectionStringBuilder builder = new() { DataSource = databasePath, Mode = SqliteOpenMode.ReadWriteCreate }; return new SqliteConnection(builder.ToString()); } }