fe9044115b
Server-007: GalaxyHierarchyProjector re-filtered the whole hierarchy per page (O(total) paging). It now memoizes the filtered list per cache-entry + filter signature so subsequent pages are an O(pageSize) slice. Server-008: WatchDeployEvents re-resolved browse subtrees and rebuilt globs per streamed event. ResolveBrowseSubtrees is hoisted out of the loop and GalaxyGlobMatcher caches compiled Regex instances per pattern. Server-009: auth-store connections used no busy timeout or WAL. A new OpenConnectionAsync applies journal_mode=WAL and a busy_timeout; all auth call sites use it. docs/Authentication.md updated. Server-010: the dashboard rendered Rotate/Revoke for revoked keys, where Rotate silently reactivates them. ApiKeysPage now shows actions only for Active keys. docs/Authentication.md updated. Server-011: WorkerAlarmRpcDispatcher converted to a primary constructor and brought in line with module conventions. Server-012: CLAUDE.md corrected to the canonical *:* scope strings. Server-013 (partly re-triaged): three named coverage gaps were already closed; the genuine gap (WorkerExecutableValidator) is now covered. Server-014: rewrote stale "alarm path not yet wired" comments in MxAccessGatewayService to describe the production WorkerAlarmRpcDispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
340 lines
13 KiB
C#
340 lines
13 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="SqliteAuthStore"/>.
|
|
/// </summary>
|
|
public sealed class SqliteAuthStoreTests : IDisposable
|
|
{
|
|
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
|
/// <summary>
|
|
/// Verifies that MigrateAsync initializes the database schema.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
|
|
{
|
|
string databasePath = CreateTempDatabasePath();
|
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
|
|
|
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that MigrateAsync migrates and is idempotent.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently()
|
|
{
|
|
string databasePath = CreateTempDatabasePath();
|
|
await CreateVersionZeroDatabaseAsync(databasePath);
|
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
|
|
|
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that gateway startup fails with a newer schema version.
|
|
/// </summary>
|
|
[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<AuthStoreMigrationException>(
|
|
() => app.StartAsync(CancellationToken.None));
|
|
|
|
Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that FindActiveByKeyIdAsync returns an active key.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
|
|
{
|
|
string databasePath = CreateTempDatabasePath();
|
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
|
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
|
await InsertApiKeyAsync(databasePath, revokedUtc: null);
|
|
|
|
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that FindActiveByKeyIdAsync returns null for a revoked key.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull()
|
|
{
|
|
string databasePath = CreateTempDatabasePath();
|
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
|
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
|
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
|
|
|
|
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the audit store persists audit events.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent()
|
|
{
|
|
string databasePath = CreateTempDatabasePath();
|
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
|
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
|
|
|
IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();
|
|
|
|
await auditStore.AppendAsync(
|
|
new ApiKeyAuditEntry(
|
|
KeyId: "test-key",
|
|
EventType: "lookup",
|
|
RemoteAddress: "127.0.0.1",
|
|
Details: "matched active key"),
|
|
CancellationToken.None);
|
|
|
|
IReadOnlyList<ApiKeyAuditRecord> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that <see cref="AuthSqliteConnectionFactory.OpenConnectionAsync"/> opens
|
|
/// the auth database in WAL journal mode so concurrent readers and writers degrade
|
|
/// gracefully instead of surfacing <c>SQLITE_BUSY</c> on the request path.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout()
|
|
{
|
|
string databasePath = CreateTempDatabasePath();
|
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
|
AuthSqliteConnectionFactory factory = services.GetRequiredService<AuthSqliteConnectionFactory>();
|
|
|
|
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<string, string?>
|
|
{
|
|
["MxGateway:Authentication:SqlitePath"] = databasePath
|
|
})
|
|
.Build();
|
|
|
|
ServiceCollection services = new();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
services.AddGatewayConfiguration();
|
|
services.AddSqliteAuthStore();
|
|
|
|
return services.BuildServiceProvider(validateScopes: true);
|
|
}
|
|
|
|
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
|
|
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<string>(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<int> 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<bool> 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());
|
|
}
|
|
}
|