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:
@@ -80,10 +80,10 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.2" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-side projection of one inbound API key, as surfaced by the management seam.
|
||||||
|
/// Hash-free by construction — the secret is never carried here; it is shown ONCE at
|
||||||
|
/// creation via <see cref="InboundApiKeyCreated"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="KeyId">Stable key identifier (the middle segment of the token).</param>
|
||||||
|
/// <param name="Name">Operator-facing display name.</param>
|
||||||
|
/// <param name="Enabled">True while the key is active (not revoked/disabled).</param>
|
||||||
|
/// <param name="Methods">The API-method names this key is scoped to call, sorted ordinally.</param>
|
||||||
|
/// <param name="CreatedUtc">When the key was created.</param>
|
||||||
|
/// <param name="LastUsedUtc">When the key last authenticated a request, if ever.</param>
|
||||||
|
public sealed record InboundApiKeyInfo(
|
||||||
|
string KeyId,
|
||||||
|
string Name,
|
||||||
|
bool Enabled,
|
||||||
|
IReadOnlyList<string> Methods,
|
||||||
|
DateTimeOffset CreatedUtc,
|
||||||
|
DateTimeOffset? LastUsedUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of creating a key. <see cref="Token"/> is the assembled bearer token
|
||||||
|
/// (<c>sbk_<keyId>_<secret></c>) and is the ONLY moment the secret is available —
|
||||||
|
/// it is never retrievable afterwards.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="KeyId">The new key's identifier.</param>
|
||||||
|
/// <param name="Token">The bearer token, shown once.</param>
|
||||||
|
public sealed record InboundApiKeyCreated(string KeyId, string Token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// App-facing management seam for inbound API keys. This is the single shared path CLI
|
||||||
|
/// and CentralUI use to create / list / enable / disable / delete inbound keys and edit
|
||||||
|
/// their method-scopes. The interface lives in Commons and is deliberately free of any
|
||||||
|
/// dependency on the underlying auth library, so consumers depend only on this contract.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInboundApiKeyAdmin
|
||||||
|
{
|
||||||
|
/// <summary>Creates a new key scoped to <paramref name="methods"/> and returns its
|
||||||
|
/// identifier plus the bearer token (shown once).</summary>
|
||||||
|
Task<InboundApiKeyCreated> CreateAsync(
|
||||||
|
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Lists all inbound keys (hash-free projection).</summary>
|
||||||
|
Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Enables or disables a key without changing its secret. Returns false if
|
||||||
|
/// the key does not exist.</summary>
|
||||||
|
Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Replaces the method-scope set on a key without changing its secret.
|
||||||
|
/// Returns false if the key does not exist.</summary>
|
||||||
|
Task<bool> SetMethodsAsync(
|
||||||
|
string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Removes a key (revoke-then-delete). Returns false if the key could not be
|
||||||
|
/// deleted.</summary>
|
||||||
|
Task<bool> DeleteAsync(string keyId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the method-scope set for a key, or an empty list if not found.</summary>
|
||||||
|
Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the identifiers of all keys whose scopes contain
|
||||||
|
/// <paramref name="methodName"/>.</summary>
|
||||||
|
Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -144,6 +144,26 @@ try
|
|||||||
|
|
||||||
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
|
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
|
||||||
|
|
||||||
|
// Inbound-API key re-arch (C1), additive: expose the library admin facade
|
||||||
|
// (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin)
|
||||||
|
// in the SAME container as AddZbApiKeyAuth, so CLI + CentralUI later create /
|
||||||
|
// list / enable / disable / delete inbound keys and edit their method-scopes
|
||||||
|
// through one shared path. AddZbApiKeyAuth registers the stores/pepper/migrator
|
||||||
|
// but NOT ApiKeyAdminCommands itself, so it is composed here. CentralUI resolves
|
||||||
|
// from this same provider (it is registered via AddCentralUI() above), so the
|
||||||
|
// seam is reachable from both the ManagementActor and CentralUI pages — exactly
|
||||||
|
// as IInboundApiRepository already is.
|
||||||
|
builder.Services.AddSingleton(sp => new ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands(
|
||||||
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyOptions>>().Value,
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore>(),
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore>(),
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.IApiKeyPepperProvider>(),
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(),
|
||||||
|
TimeProvider.System));
|
||||||
|
builder.Services.AddSingleton<
|
||||||
|
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin,
|
||||||
|
LibraryInboundApiKeyAdmin>();
|
||||||
|
|
||||||
builder.Services.AddManagementService();
|
builder.Services.AddManagementService();
|
||||||
|
|
||||||
var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"]
|
var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"]
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements the Commons <see cref="IInboundApiKeyAdmin"/> management seam over the shared
|
||||||
|
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin facade (<see cref="ApiKeyAdminCommands"/>). This is the
|
||||||
|
/// single shared path through which CLI and CentralUI create / list / enable / disable / delete
|
||||||
|
/// inbound keys and edit their method-scopes, so all front-ends drive identical library behaviour.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Mapping from the library projection to the app DTO:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>A key is "enabled" iff its <c>RevokedUtc</c> is null.</item>
|
||||||
|
/// <item>"Methods" are the library's <c>Scopes</c>, sorted ordinally for a stable display order.</item>
|
||||||
|
/// <item>Delete is best-effort revoke-then-delete: the library only deletes already-revoked keys,
|
||||||
|
/// so we revoke first (a harmless no-op when already revoked) and the delete is authoritative.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LibraryInboundApiKeyAdmin : IInboundApiKeyAdmin
|
||||||
|
{
|
||||||
|
private readonly ApiKeyAdminCommands _admin;
|
||||||
|
|
||||||
|
/// <summary>Creates the seam over the supplied library admin command set.</summary>
|
||||||
|
/// <param name="admin">The shared library admin facade.</param>
|
||||||
|
public LibraryInboundApiKeyAdmin(ApiKeyAdminCommands admin)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(admin);
|
||||||
|
_admin = admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<InboundApiKeyCreated> CreateAsync(
|
||||||
|
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(methods);
|
||||||
|
|
||||||
|
var keyId = Guid.NewGuid().ToString("N");
|
||||||
|
var result = await _admin.CreateKeyAsync(
|
||||||
|
keyId, name, methods.ToHashSet(StringComparer.Ordinal),
|
||||||
|
constraintsJson: null, remoteAddress: null, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Token is non-null on create success; CreateKeyAsync throws rather than returning a
|
||||||
|
// null token on the failure path, so a successful return always carries the secret.
|
||||||
|
return new InboundApiKeyCreated(result.KeyId, result.Token!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var items = await _admin.ListKeysAsync(ct).ConfigureAwait(false);
|
||||||
|
var result = new List<InboundApiKeyInfo>(items.Count);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
result.Add(new InboundApiKeyInfo(
|
||||||
|
KeyId: item.KeyId,
|
||||||
|
Name: item.DisplayName,
|
||||||
|
Enabled: item.RevokedUtc is null,
|
||||||
|
Methods: item.Scopes.OrderBy(s => s, StringComparer.Ordinal).ToList(),
|
||||||
|
CreatedUtc: item.CreatedUtc,
|
||||||
|
LastUsedUtc: item.LastUsedUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default) =>
|
||||||
|
(await _admin.SetEnabledAsync(keyId, enabled, remoteAddress: null, ct).ConfigureAwait(false)).Succeeded;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> SetMethodsAsync(
|
||||||
|
string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(methods);
|
||||||
|
return (await _admin.SetScopesAsync(
|
||||||
|
keyId, methods.ToHashSet(StringComparer.Ordinal), remoteAddress: null, ct)
|
||||||
|
.ConfigureAwait(false)).Succeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Best-effort revoke first so the library permits the delete (it only deletes
|
||||||
|
// already-revoked keys). Revoking an already-disabled key is a harmless no-op;
|
||||||
|
// the delete result is authoritative.
|
||||||
|
await _admin.RevokeKeyAsync(keyId, remoteAddress: null, ct).ConfigureAwait(false);
|
||||||
|
return (await _admin.DeleteKeyAsync(keyId, remoteAddress: null, ct).ConfigureAwait(false)).Succeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var keys = await ListAsync(ct).ConfigureAwait(false);
|
||||||
|
var match = keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||||
|
return match?.Methods ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var keys = await ListAsync(ct).ConfigureAwait(false);
|
||||||
|
return keys
|
||||||
|
.Where(k => k.Methods.Contains(methodName, StringComparer.Ordinal))
|
||||||
|
.Select(k => k.KeyId)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@
|
|||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
|
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" />
|
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
|
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
|
||||||
|
<!-- Inbound-API key re-arch (C1): LibraryInboundApiKeyAdmin implements the
|
||||||
|
Commons IInboundApiKeyAdmin management seam over the shared admin facade
|
||||||
|
(ApiKeyAdminCommands). Security is the one project referenced by BOTH the
|
||||||
|
Host (ManagementActor, via ManagementService) and CentralUI, and it already
|
||||||
|
carries the rest of the Auth family — so the impl lives here. -->
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" />
|
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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. -->
|
<!-- 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.Abstractions" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user