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; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; /// /// Inbound-API key re-arch (C1): unit tests for , the /// app-side management seam that maps the Commons contract /// onto the shared ZB.MOM.WW.Auth.ApiKeys admin facade (). /// These run the REAL library against a per-test temp SQLite database (mirroring the library's /// own ApiKeyAdminCommandsTests), so the mapping — token assembly, enabled flag from /// RevokedUtc, scope ↔ method translation, revoke-then-delete — is verified end-to-end. /// Tokens are only ever minted via . /// public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime { private const string Pepper = "test-pepper-at-least-16-chars-long"; private const string TokenPrefix = "sbk"; private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"inbound-key-admin-{Guid.NewGuid():N}.sqlite"); private AuthSqliteConnectionFactory _factory = null!; private SqliteAuthStoreMigrator _migrator = null!; private SqliteApiKeyAdminStore _adminStore = null!; private SqliteApiKeyAuditStore _auditStore = null!; private ApiKeyOptions _options = null!; private IInboundApiKeyAdmin _sut = null!; /// Creates the temporary store, migrates it and builds the admin services. /// A task that represents the asynchronous operation. public async Task InitializeAsync() { _factory = new AuthSqliteConnectionFactory(_dbPath); _migrator = new SqliteAuthStoreMigrator(_factory); _adminStore = new SqliteApiKeyAdminStore(_factory); _auditStore = new SqliteApiKeyAuditStore(_factory); _options = new ApiKeyOptions { TokenPrefix = TokenPrefix, SqlitePath = _dbPath }; // Create the schema once up front so every command runs against a ready store. await _migrator.MigrateAsync(CancellationToken.None); var commands = new ApiKeyAdminCommands( _options, _adminStore, _auditStore, new FakePepperProvider(Pepper), _migrator, new FixedTimeProvider(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero))); _sut = new LibraryInboundApiKeyAdmin(commands); } /// Verifies that CreateAsync throws for a null or whitespace name. /// The key name under test. /// A task that represents the asynchronous operation. [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task CreateAsync_NullOrWhitespaceName_Throws(string? name) { // ThrowIfNullOrWhiteSpace throws ArgumentNullException for null and ArgumentException // for empty/whitespace; both are ArgumentException subtypes, so ThrowsAnyAsync covers all. await Assert.ThrowsAnyAsync( () => _sut.CreateAsync(name!, new[] { "MethodA" }, CancellationToken.None)); } /// Verifies that CreateAsync returns a key id and a token that starts with the sbk prefix. /// A task that represents the asynchronous operation. [Fact] public async Task CreateAsync_ReturnsKeyIdAndToken_TokenStartsWith_sbk() { InboundApiKeyCreated created = await _sut.CreateAsync( "Service A", new[] { "MethodA", "MethodB" }, CancellationToken.None); Assert.False(string.IsNullOrWhiteSpace(created.KeyId)); Assert.StartsWith($"{TokenPrefix}_{created.KeyId}_", created.Token); // The created key then appears in ListAsync, enabled, with the given methods. IReadOnlyList keys = await _sut.ListAsync(CancellationToken.None); InboundApiKeyInfo info = Assert.Single(keys, k => k.KeyId == created.KeyId); Assert.Equal("Service A", info.Name); Assert.True(info.Enabled); Assert.Equal(new[] { "MethodA", "MethodB" }, info.Methods); } /// Verifies that disabling then enabling a key toggles the listed enabled flag. /// A task that represents the asynchronous operation. [Fact] public async Task SetEnabledAsync_False_ThenTrue_Toggles_ListedEnabledFlag() { InboundApiKeyCreated created = await _sut.CreateAsync( "Toggle Key", new[] { "MethodA" }, CancellationToken.None); Assert.True((await SingleAsync(created.KeyId)).Enabled); Assert.True(await _sut.SetEnabledAsync(created.KeyId, enabled: false, CancellationToken.None)); Assert.False((await SingleAsync(created.KeyId)).Enabled); Assert.True(await _sut.SetEnabledAsync(created.KeyId, enabled: true, CancellationToken.None)); Assert.True((await SingleAsync(created.KeyId)).Enabled); } /// Verifies that SetEnabledAsync returns false for an unknown key. /// A task that represents the asynchronous operation. [Fact] public async Task SetEnabledAsync_UnknownKey_ReturnsFalse() { Assert.False(await _sut.SetEnabledAsync("does-not-exist", enabled: false, CancellationToken.None)); } /// Verifies that SetMethodsAsync replaces the key's methods. /// A task that represents the asynchronous operation. [Fact] public async Task SetMethodsAsync_ReplacesMethods() { InboundApiKeyCreated created = await _sut.CreateAsync( "Scope Key", new[] { "Old1", "Old2" }, CancellationToken.None); Assert.True(await _sut.SetMethodsAsync( created.KeyId, new[] { "New1", "New2", "New3" }, CancellationToken.None)); // ListAsync reflects the new (ordinally-sorted) methods. InboundApiKeyInfo info = await SingleAsync(created.KeyId); Assert.Equal(new[] { "New1", "New2", "New3" }, info.Methods); // GetMethodsForKeyAsync returns them. IReadOnlyList methods = await _sut.GetMethodsForKeyAsync(created.KeyId, CancellationToken.None); Assert.Equal(new[] { "New1", "New2", "New3" }, methods); // GetKeysForMethodAsync finds the key for an in-scope method, not for an out-of-scope one. IReadOnlyList inScope = await _sut.GetKeysForMethodAsync("New2", CancellationToken.None); Assert.Contains(created.KeyId, inScope); IReadOnlyList outOfScope = await _sut.GetKeysForMethodAsync("Old1", CancellationToken.None); Assert.DoesNotContain(created.KeyId, outOfScope); } /// Verifies that DeleteAsync removes the key. /// A task that represents the asynchronous operation. [Fact] public async Task DeleteAsync_RemovesKey() { InboundApiKeyCreated created = await _sut.CreateAsync( "Delete Key", new[] { "MethodA" }, CancellationToken.None); // Sanity: present before delete. Assert.Contains( await _sut.ListAsync(CancellationToken.None), k => k.KeyId == created.KeyId); Assert.True(await _sut.DeleteAsync(created.KeyId, CancellationToken.None)); // After delete, ListAsync no longer contains it. Assert.DoesNotContain( await _sut.ListAsync(CancellationToken.None), k => k.KeyId == created.KeyId); } /// Verifies that GetKeysForMethodAsync returns only the keys scoped to that method. /// A task that represents the asynchronous operation. [Fact] public async Task GetKeysForMethodAsync_ReturnsOnlyKeysScopedToThatMethod() { InboundApiKeyCreated a = await _sut.CreateAsync( "Key A", new[] { "Shared", "OnlyA" }, CancellationToken.None); InboundApiKeyCreated b = await _sut.CreateAsync( "Key B", new[] { "Shared", "OnlyB" }, CancellationToken.None); InboundApiKeyCreated c = await _sut.CreateAsync( "Key C", new[] { "Unrelated" }, CancellationToken.None); IReadOnlyList sharedKeys = await _sut.GetKeysForMethodAsync("Shared", CancellationToken.None); Assert.Equal( new[] { a.KeyId, b.KeyId }.OrderBy(x => x, StringComparer.Ordinal), sharedKeys.OrderBy(x => x, StringComparer.Ordinal)); Assert.DoesNotContain(c.KeyId, sharedKeys); IReadOnlyList onlyAKeys = await _sut.GetKeysForMethodAsync("OnlyA", CancellationToken.None); Assert.Equal(new[] { a.KeyId }, onlyAKeys); // A method nobody is scoped to yields no keys. Assert.Empty(await _sut.GetKeysForMethodAsync("Nobody", CancellationToken.None)); } /// Verifies that GetMethodsForKeyAsync returns an empty result for an unknown key. /// A task that represents the asynchronous operation. [Fact] public async Task GetMethodsForKeyAsync_UnknownKey_ReturnsEmpty() { Assert.Empty(await _sut.GetMethodsForKeyAsync("does-not-exist", CancellationToken.None)); } private async Task SingleAsync(string keyId) { IReadOnlyList keys = await _sut.ListAsync(CancellationToken.None); return Assert.Single(keys, k => k.KeyId == keyId); } /// Clears SQLite connection pools and deletes the temporary database files. /// A task that represents the asynchronous operation. public Task DisposeAsync() { // SqliteConnection pooling can hold the file open; clear pools before deleting // so the temp DB (and its -wal/-shm sidecars) are removed. SqliteConnection.ClearAllPools(); foreach (var suffix in new[] { "", "-wal", "-shm" }) { var path = _dbPath + suffix; if (File.Exists(path)) { File.Delete(path); } } return Task.CompletedTask; } private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider { /// Returns the fixed pepper. /// The configured pepper value. public string? GetPepper() => pepper; } private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider { private readonly DateTimeOffset _now = now; /// public override DateTimeOffset GetUtcNow() => _now; } }