203 lines
8.2 KiB
C#
203 lines
8.2 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);
|
|
}
|
|
|
|
[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;
|
|
}
|
|
}
|