using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Admin; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.Tests; public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime { private const string Pepper = "test-pepper-value"; private readonly string _dbPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db"); private AuthSqliteConnectionFactory _factory = null!; private SqliteAuthStoreMigrator _migrator = null!; private SqliteApiKeyAdminStore _admin = null!; private SqliteApiKeyStore _read = null!; private SqliteApiKeyAuditStore _audit = null!; private ApiKeyOptions _options = null!; public Task InitializeAsync() { _factory = new AuthSqliteConnectionFactory(_dbPath); _migrator = new SqliteAuthStoreMigrator(_factory); _admin = new SqliteApiKeyAdminStore(_factory); _read = new SqliteApiKeyStore(_factory); _audit = new SqliteApiKeyAuditStore(_factory); _options = new ApiKeyOptions { TokenPrefix = "mxgw", SqlitePath = _dbPath }; return Task.CompletedTask; } private ApiKeyAdminCommands BuildCommands(string? pepper = Pepper) => new( _options, _admin, _audit, new FakePepperProvider(pepper), _migrator); // --- init-db --- [Fact] public async Task InitDb_CreatesTables_AndAppendsAudit() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(remoteAddress: "10.0.0.1", CancellationToken.None); // Tables exist: a create after init must succeed. Assert.True(await TableExistsAsync("api_keys")); Assert.True(await TableExistsAsync("api_key_audit")); IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); Assert.Single(recent, e => e.EventType == "init-db"); } // --- create-key --- [Fact] public async Task CreateKey_ReturnsAssembledToken_KeyFindable_AndAuditAppended() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); CreateKeyResult result = await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read", "write"], StringComparer.Ordinal), constraintsJson: """{"ipAllow":["10.0.0.0/8"]}""", remoteAddress: "10.0.0.1", CancellationToken.None); Assert.Equal("key-1", result.KeyId); Assert.StartsWith("mxgw_key-1_", result.Token); ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(found); // The returned token's secret matches what is stored (hash of parsed secret == stored hash). string secret = ParseSecret(result.Token); byte[] expected = ApiKeySecretHasher.Hash(secret, Pepper); Assert.True(found!.SecretHash.SequenceEqual(expected)); // Exactly one create-key audit row. IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); Assert.Single(recent, e => e.EventType == "create-key"); } [Fact] public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId() { // Auth-005: KeyPrefix is the bare token prefix ("mxgw"), NOT "mxgw_key-1". The key id is // already its own column; embedding it produced a self-referential value that disagreed with // the read/test paths and confused admin tooling. ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), constraintsJson: null, remoteAddress: null, CancellationToken.None); ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(found); Assert.Equal("mxgw", found!.KeyPrefix); // The same bare prefix is surfaced by the admin list projection. IReadOnlyList listed = await commands.ListKeysAsync(CancellationToken.None); ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1"); Assert.Equal("mxgw", item.KeyPrefix); } [Fact] public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit() { ApiKeyAdminCommands commands = BuildCommands(pepper: null); await new ApiKeyAdminCommands(_options, _admin, _audit, new FakePepperProvider(Pepper), _migrator) .InitDbAsync(null, CancellationToken.None); int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count; await Assert.ThrowsAsync(() => commands.CreateKeyAsync( "key-x", "No Pepper", new HashSet(StringComparer.Ordinal), constraintsJson: null, remoteAddress: null, CancellationToken.None)); // No key created, no audit appended. Assert.Null(await _read.FindByKeyIdAsync("key-x", CancellationToken.None)); int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count; Assert.Equal(auditCountBefore, auditCountAfter); } [Fact] public async Task CreateKey_KeyIdContainsUnderscore_ThrowsArgumentException() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await Assert.ThrowsAsync(() => commands.CreateKeyAsync( "a_b", "Service A", new HashSet(StringComparer.Ordinal), constraintsJson: null, remoteAddress: null, CancellationToken.None)); } // --- list-keys --- [Fact] public async Task ListKeys_ReturnsCreatedKey_WithoutSecretMaterial() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), constraintsJson: null, remoteAddress: null, CancellationToken.None); IReadOnlyList keys = await commands.ListKeysAsync(CancellationToken.None); ApiKeyListItem item = Assert.Single(keys, k => k.KeyId == "key-1"); Assert.Equal("Service A", item.DisplayName); Assert.Contains("read", item.Scopes); Assert.Null(item.RevokedUtc); // ApiKeyListItem has NO secret/hash member by construction (compile-time guarantee). Assert.DoesNotContain( typeof(ApiKeyListItem).GetProperties(), p => p.Name.Contains("Hash", StringComparison.OrdinalIgnoreCase) || p.Name.Contains("Secret", StringComparison.OrdinalIgnoreCase)); } // --- revoke-key --- [Fact] public async Task RevokeKey_DeactivatesKey_AndAppendsAudit() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), null, null, CancellationToken.None); KeyActionResult result = await commands.RevokeKeyAsync("key-1", "10.0.0.1", CancellationToken.None); Assert.True(result.Succeeded); Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); Assert.Single(recent, e => e.EventType == "revoke-key"); } // --- rotate-key --- [Fact] public async Task RotateKey_ReturnsNewToken_OldSecretFails_NewSecretWorks_AndAuditAppended() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); CreateKeyResult created = await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), null, null, CancellationToken.None); string oldSecret = ParseSecret(created.Token); CreateKeyResult rotated = await commands.RotateKeyAsync("key-1", "10.0.0.1", CancellationToken.None); Assert.Equal("key-1", rotated.KeyId); Assert.NotEqual(created.Token, rotated.Token); ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(found); // Old secret no longer verifies; new one does. Assert.False(ApiKeySecretHasher.Verify(oldSecret, Pepper, found!.SecretHash)); Assert.True(ApiKeySecretHasher.Verify(ParseSecret(rotated.Token), Pepper, found.SecretHash)); IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); Assert.Single(recent, e => e.EventType == "rotate-key"); } [Fact] public async Task RotateKey_UnknownKey_ReturnsFailureResult_AndAppendsAudit() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count; CreateKeyResult result = await commands.RotateKeyAsync("missing", null, CancellationToken.None); Assert.Null(result.Token); // Auditing failed/not-found attempts is INTENTIONAL (security trail): exactly one rotate-key row. IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); int newAuditRows = recent.Count - auditCountBefore; Assert.Equal(1, newAuditRows); ApiKeyAuditEntry auditRow = recent.First(e => e.EventType == "rotate-key"); Assert.Equal("not-found", auditRow.Details); } [Fact] public async Task RotateKey_PepperUnavailable_Throws_HashUnchanged_AndAppendsNoAudit() { // Arrange: create a key with a valid pepper. ApiKeyAdminCommands setupCommands = BuildCommands(pepper: Pepper); await setupCommands.InitDbAsync(null, CancellationToken.None); await setupCommands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), constraintsJson: null, remoteAddress: null, CancellationToken.None); ApiKeyRecord? before = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(before); byte[] hashBefore = before!.SecretHash; int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count; // Act: rotate with no pepper available. ApiKeyAdminCommands nopepper = BuildCommands(pepper: null); await Assert.ThrowsAsync(() => nopepper.RotateKeyAsync("key-1", null, CancellationToken.None)); // Assert: stored hash is unchanged. ApiKeyRecord? after = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(after); Assert.True(after!.SecretHash.SequenceEqual(hashBefore)); // Assert: no rotate-key audit row was appended (RequirePepper fires before any store/audit write). int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count; Assert.Equal(auditCountBefore, auditCountAfter); } // --- set-scopes / enable-disable --- [Fact] public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), null, null, CancellationToken.None); // Disable, then re-enable, then replace scopes. KeyActionResult disabled = await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None); Assert.True(disabled.Succeeded); Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); KeyActionResult enabled = await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None); Assert.True(enabled.Succeeded); Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); KeyActionResult scoped = await commands.SetScopesAsync( "key-1", new HashSet(["read", "write"], StringComparer.Ordinal), "10.0.0.1", CancellationToken.None); Assert.True(scoped.Succeeded); IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); Assert.Single(recent, e => e.EventType == "disable-key"); Assert.Single(recent, e => e.EventType == "enable-key"); Assert.Single(recent, e => e.EventType == "set-scopes"); IReadOnlyList listed = await commands.ListKeysAsync(CancellationToken.None); ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1"); Assert.True(item.Scopes.SetEquals(new HashSet(["read", "write"], StringComparer.Ordinal))); } [Fact] public async Task SetScopesAsync_NullScopes_Throws() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await Assert.ThrowsAnyAsync(() => commands.SetScopesAsync("key-1", null!, null, CancellationToken.None)); } // --- delete-key --- [Fact] public async Task DeleteKey_OnlyWorksAfterRevoke_AndAppendsAudit() { ApiKeyAdminCommands commands = BuildCommands(); await commands.InitDbAsync(null, CancellationToken.None); await commands.CreateKeyAsync( "key-1", "Service A", new HashSet(["read"], StringComparer.Ordinal), null, null, CancellationToken.None); // Delete before revoke fails. KeyActionResult beforeRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None); Assert.False(beforeRevoke.Succeeded); Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None)); await commands.RevokeKeyAsync("key-1", null, CancellationToken.None); KeyActionResult afterRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None); Assert.True(afterRevoke.Succeeded); Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None)); // Two delete-key audit rows (one failed attempt, one success) — each verb audits exactly once per call. IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); Assert.Equal(2, recent.Count(e => e.EventType == "delete-key")); } // --- helpers --- private static string ParseSecret(string? token) { // token = "__"; secret may contain underscores. Assert.NotNull(token); string[] parts = token!.Split('_', 3); return parts[2]; } private async Task 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 > 0; } private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider { public string? GetPepper() => pepper; } public Task DisposeAsync() { SqliteConnection.ClearAllPools(); TryDelete(_dbPath); TryDelete(_dbPath + "-wal"); TryDelete(_dbPath + "-shm"); return Task.CompletedTask; } 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. } } }