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!; 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); } [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)); } [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); } [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); } [Fact] public async Task SetEnabledAsync_UnknownKey_ReturnsFalse() { Assert.False(await _sut.SetEnabledAsync("does-not-exist", enabled: false, CancellationToken.None)); } [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); } [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); } [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)); } [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); } 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 { public string? GetPepper() => pepper; } private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider { private readonly DateTimeOffset _now = now; public override DateTimeOffset GetUtcNow() => _now; } }