feat(auth): ScadaBridge re-pin Auth 0.1.3 + add IInboundApiKeyAdmin seam over library admin facade (re-arch C1, additive)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@
|
||||
<!-- Task 1.2: the LDAP tests construct the shared library service / options directly. -->
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
|
||||
<!-- C1: LibraryInboundApiKeyAdmin tests construct the real library Sqlite stores +
|
||||
ApiKeyAdminCommands against a temp SQLite DB (mirroring the library's own
|
||||
ApiKeyAdminCommands tests). -->
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user