using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MxGateway.Server.Configuration; using MxGateway.Server.Security.Authentication; namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeyAdminCliRunnerTests : IDisposable { private readonly List _tempDirectories = []; /// Verifies that CreateKeyAsync creates an authenticating key and audits the action. [Fact] public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits() { await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); ApiKeyAdminCliRunner runner = services.GetRequiredService(); StringWriter output = new(); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.CreateKey, Json: true, SqlitePath: null, Pepper: null, KeyId: "operator01", DisplayName: "Operator", Scopes: new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }, Constraints: ApiKeyConstraints.Empty), output, CancellationToken.None); string apiKey = ReadApiKey(output.ToString()); IApiKeyVerifier verifier = services.GetRequiredService(); ApiKeyVerificationResult verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None); Assert.True(verification.Succeeded); Assert.NotNull(verification.Identity); Assert.Equal("operator01", verification.Identity.KeyId); Assert.Contains("session:open", verification.Identity.Scopes); IReadOnlyList auditRecords = await services .GetRequiredService() .ListRecentAsync(10, CancellationToken.None); Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01"); } /// Verifies that ListKeysAsync does not print the raw secret. [Fact] public async Task ListKeysAsync_DoesNotPrintRawSecret() { await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); ApiKeyAdminCliRunner runner = services.GetRequiredService(); string apiKey = await CreateKeyAsync(runner, "operator01"); StringWriter listOutput = new(); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.ListKeys, Json: true, SqlitePath: null, Pepper: null, KeyId: null, DisplayName: null, Scopes: new HashSet(StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty), listOutput, CancellationToken.None); string listJson = listOutput.ToString(); Assert.Contains("operator01", listJson, StringComparison.Ordinal); Assert.DoesNotContain(apiKey, listJson, StringComparison.Ordinal); Assert.DoesNotContain(ApiKeySecret(apiKey), listJson, StringComparison.Ordinal); Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase); } /// Verifies that RevokeKeyAsync causes the revoked key to fail verification and is audited. [Fact] public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits() { await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); ApiKeyAdminCliRunner runner = services.GetRequiredService(); string apiKey = await CreateKeyAsync(runner, "operator01"); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.RevokeKey, Json: true, SqlitePath: null, Pepper: null, KeyId: "operator01", DisplayName: null, Scopes: new HashSet(StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty), TextWriter.Null, CancellationToken.None); ApiKeyVerificationResult verification = await services .GetRequiredService() .VerifyAsync($"Bearer {apiKey}", CancellationToken.None); Assert.False(verification.Succeeded); Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure); IReadOnlyList auditRecords = await services .GetRequiredService() .ListRecentAsync(10, CancellationToken.None); Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01"); } /// Verifies that RotateKeyAsync prints the new secret once and invalidates the old secret. [Fact] public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret() { await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); ApiKeyAdminCliRunner runner = services.GetRequiredService(); string oldApiKey = await CreateKeyAsync(runner, "operator01"); StringWriter rotateOutput = new(); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.RotateKey, Json: true, SqlitePath: null, Pepper: null, KeyId: "operator01", DisplayName: null, Scopes: new HashSet(StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty), rotateOutput, CancellationToken.None); string rotateJson = rotateOutput.ToString(); string newApiKey = ReadApiKey(rotateJson); Assert.NotEqual(oldApiKey, newApiKey); Assert.Equal(1, CountOccurrences(rotateJson, newApiKey)); IApiKeyVerifier verifier = services.GetRequiredService(); ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None); ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None); Assert.False(oldVerification.Succeeded); Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, oldVerification.Failure); Assert.True(newVerification.Succeeded); } /// Verifies that CreateKeyAsync prints the raw secret exactly once. [Fact] public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce() { await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); ApiKeyAdminCliRunner runner = services.GetRequiredService(); StringWriter output = new(); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.CreateKey, Json: true, SqlitePath: null, Pepper: null, KeyId: "operator01", DisplayName: "Operator", Scopes: new HashSet(StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty), output, CancellationToken.None); string json = output.ToString(); string apiKey = ReadApiKey(json); Assert.Equal(1, CountOccurrences(json, apiKey)); Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey))); } [Fact] public async Task CreateKeyAsync_WithConstraints_PersistsConstraints() { await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); ApiKeyAdminCliRunner runner = services.GetRequiredService(); StringWriter output = new(); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.CreateKey, Json: true, SqlitePath: null, Pepper: null, KeyId: "operator01", DisplayName: "Operator", Scopes: new HashSet(StringComparer.Ordinal) { "metadata:read" }, Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = ["Area1/*"], ReadAlarmOnly = true, }), output, CancellationToken.None); string apiKey = ReadApiKey(output.ToString()); ApiKeyVerificationResult verification = await services .GetRequiredService() .VerifyAsync($"Bearer {apiKey}", CancellationToken.None); Assert.True(verification.Succeeded); Assert.Equal(["Area1/*"], verification.Identity!.EffectiveConstraints.BrowseSubtrees); Assert.True(verification.Identity.EffectiveConstraints.ReadAlarmOnly); } private static async Task CreateKeyAsync(ApiKeyAdminCliRunner runner, string keyId) { StringWriter output = new(); await runner.RunAsync( new ApiKeyAdminCommand( Kind: ApiKeyAdminCommandKind.CreateKey, Json: true, SqlitePath: null, Pepper: null, KeyId: keyId, DisplayName: "Operator", Scopes: new HashSet(StringComparer.Ordinal) { "session:open" }, Constraints: ApiKeyConstraints.Empty), output, CancellationToken.None); return ReadApiKey(output.ToString()); } private static ServiceProvider BuildServices(string databasePath) { IConfigurationRoot configuration = new ConfigurationBuilder() .AddInMemoryCollection( new Dictionary { ["MxGateway:Authentication:SqlitePath"] = databasePath, ["MxGateway:ApiKeyPepper"] = "test-pepper" }) .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-cli-tests"); _tempDirectories.Add(directory); return directory.DatabasePath(); } private static string ReadApiKey(string json) { using JsonDocument document = JsonDocument.Parse(json); return document.RootElement.GetProperty("ApiKey").GetString() ?? throw new InvalidOperationException("API key was not present in command output."); } private static string ApiKeySecret(string apiKey) { string[] parts = apiKey.Split('_', 3); return parts[2]; } private static int CountOccurrences(string value, string pattern) { int count = 0; int index = 0; while ((index = value.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0) { count++; index += pattern.Length; } return count; } }