Files

215 lines
8.7 KiB
C#

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;
/// <summary>
/// Inbound-API key re-arch (C1): unit tests for <see cref="LibraryInboundApiKeyAdmin"/>, the
/// app-side management seam that maps the Commons <see cref="IInboundApiKeyAdmin"/> contract
/// onto the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> admin facade (<see cref="ApiKeyAdminCommands"/>).
/// These run the REAL library against a per-test temp SQLite database (mirroring the library's
/// own <c>ApiKeyAdminCommandsTests</c>), so the mapping — token assembly, enabled flag from
/// <c>RevokedUtc</c>, scope ↔ method translation, revoke-then-delete — is verified end-to-end.
/// Tokens are only ever minted via <see cref="IInboundApiKeyAdmin.CreateAsync"/>.
/// </summary>
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<ArgumentException>(
() => _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<InboundApiKeyInfo> 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<string> 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<string> inScope = await _sut.GetKeysForMethodAsync("New2", CancellationToken.None);
Assert.Contains(created.KeyId, inScope);
IReadOnlyList<string> 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<string> 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<string> 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<InboundApiKeyInfo> SingleAsync(string keyId)
{
IReadOnlyList<InboundApiKeyInfo> 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;
}
}