fca978de07
Sweep of 203 source files resolving CommentChecker findings: add <summary>/<param>/<returns>/<inheritdoc> where missing, and remove resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN, Task N) from code comments. Comment/doc-only — no logic changes. Server+Tests build clean under TreatWarningsAsErrors.
410 lines
18 KiB
C#
410 lines
18 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
|
|
|
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
|
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Tests the gateway dashboard API-key management surface over the shared
|
|
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin commands and stores (the gateway is the donor). The service
|
|
/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
|
|
/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
|
|
/// rule are all proven end-to-end.
|
|
/// </summary>
|
|
public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
|
{
|
|
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
|
|
|
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
|
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
|
CreateRequest(),
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Empty(await ListAsync(services));
|
|
}
|
|
|
|
/// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
|
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
|
CreateAuthorizedUser(),
|
|
CreateRequest(),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.NotNull(result.ApiKey);
|
|
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
|
|
|
// The freshly minted token authenticates against the same store and surfaces its scopes.
|
|
ApiKeyVerification verification = await services
|
|
.GetRequiredService<IApiKeyVerifier>()
|
|
.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
|
|
Assert.True(verification.Succeeded);
|
|
Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);
|
|
|
|
// Constraints round-trip through the opaque JSON blob.
|
|
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
|
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
|
// Phase 3: Actor = operator username ("alice"), Target = managed keyId ("operator01").
|
|
Assert.Contains(audit, entry =>
|
|
entry.EventType == "dashboard-create-key"
|
|
&& entry.KeyId == "alice");
|
|
}
|
|
|
|
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
|
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
|
DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
|
|
CreateAuthorizedUser(),
|
|
CreateRequest(),
|
|
CancellationToken.None);
|
|
|
|
Assert.False(duplicate.Succeeded);
|
|
Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
|
|
|
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
|
CreateAuthorizedUser(),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
ApiKeyListItem key = Assert.Single(await ListAsync(services));
|
|
Assert.NotNull(key.RevokedUtc);
|
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
|
// Phase 3: Actor = operator username; the dashboard-revoke-key event surfaces KeyId = "alice"
|
|
// (the operator) and Details = "revoked".
|
|
Assert.Contains(audit, entry =>
|
|
entry.EventType == "dashboard-revoke-key"
|
|
&& entry.KeyId == "alice"
|
|
&& entry.Details == "revoked");
|
|
}
|
|
|
|
/// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
DashboardApiKeyManagementResult created = await service.CreateAsync(
|
|
CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
|
|
|
DashboardApiKeyManagementResult result = await service.RotateAsync(
|
|
CreateAuthorizedUser(),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.NotNull(result.ApiKey);
|
|
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
|
Assert.NotEqual(created.ApiKey, result.ApiKey);
|
|
|
|
// Old token no longer authenticates; new one does.
|
|
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
|
Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
|
|
Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
|
// Phase 3: Actor = operator username ("alice").
|
|
Assert.Contains(audit, entry =>
|
|
entry.EventType == "dashboard-rotate-key"
|
|
&& entry.KeyId == "alice"
|
|
&& entry.Details == "rotated");
|
|
}
|
|
|
|
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
|
await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);
|
|
|
|
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
|
CreateAuthorizedUser(),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Empty(await ListAsync(services));
|
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
|
// Phase 3: Actor = operator username ("alice").
|
|
Assert.Contains(audit, entry =>
|
|
entry.EventType == "dashboard-delete-key"
|
|
&& entry.KeyId == "alice"
|
|
&& entry.Details == "deleted");
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the key is still active (not revoked), the delete is refused but a
|
|
/// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
|
|
/// written — audit completeness for refused deletes.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
|
|
|
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
|
CreateAuthorizedUser(),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
|
ApiKeyAuditEntry deleteEntry = Assert.Single(
|
|
audit, entry => entry.EventType == "dashboard-delete-key");
|
|
// Phase 3: Actor = operator username ("alice"), not the managed keyId.
|
|
Assert.Equal("alice", deleteEntry.KeyId);
|
|
Assert.Equal("not-found-or-active", deleteEntry.Details);
|
|
}
|
|
|
|
/// <summary>A blank key id fails validation before any store or audit call runs.</summary>
|
|
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("\t")]
|
|
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
|
|
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
|
CreateAuthorizedUser(),
|
|
blankKeyId,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Empty(await ListAuditAsync(services));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The dashboard create path must reject a request carrying a
|
|
/// non-canonical scope string rather than persisting a key whose scope the authorization
|
|
/// resolver never matches.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateAsync_UnknownScope_DoesNotCreate()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
DashboardApiKeyManagementService service = CreateService(services);
|
|
|
|
DashboardApiKeyManagementRequest request = CreateRequest() with
|
|
{
|
|
Scopes = new HashSet<string>(
|
|
[GatewayScopes.SessionOpen, "invoke", "metadata"],
|
|
StringComparer.Ordinal),
|
|
};
|
|
|
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
|
CreateAuthorizedUser(),
|
|
request,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Empty(await ListAsync(services));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
|
|
/// the operator username as Actor and the managed keyId as Target.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
|
|
{
|
|
await using ServiceProvider services = BuildServices();
|
|
|
|
// Wire a recording writer so we can inspect the canonical AuditEvent directly (bypassing
|
|
// the CanonicalForwardingApiKeyAuditStore round-trip that ListAuditAsync uses).
|
|
RecordingAuditWriter recordingWriter = new();
|
|
DefaultHttpContext httpContext = new();
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
|
|
DashboardApiKeyManagementService service = new(
|
|
new DashboardApiKeyAuthorization(),
|
|
services.GetRequiredService<ApiKeyAdminCommands>(),
|
|
services.GetRequiredService<IApiKeyAdminStore>(),
|
|
recordingWriter,
|
|
new HttpContextAccessor { HttpContext = httpContext });
|
|
|
|
await service.CreateAsync(
|
|
CreateAuthorizedUser(),
|
|
CreateRequest(),
|
|
CancellationToken.None);
|
|
|
|
// The dashboard-create-key event emitted directly by the service (not the library's
|
|
// create-key event forwarded via the adapter) must have Actor = operator username and
|
|
// Target = managed keyId.
|
|
ZB.MOM.WW.Audit.AuditEvent dashboardEvent = Assert.Single(
|
|
recordingWriter.Events,
|
|
e => e.Action == "dashboard-create-key");
|
|
Assert.Equal("alice", dashboardEvent.Actor);
|
|
Assert.Equal("operator01", dashboardEvent.Target);
|
|
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, dashboardEvent.Outcome);
|
|
}
|
|
|
|
private DashboardApiKeyManagementService CreateService(ServiceProvider services)
|
|
{
|
|
DefaultHttpContext httpContext = new();
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
|
|
return new DashboardApiKeyManagementService(
|
|
new DashboardApiKeyAuthorization(),
|
|
services.GetRequiredService<ApiKeyAdminCommands>(),
|
|
services.GetRequiredService<IApiKeyAdminStore>(),
|
|
services.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
|
|
new HttpContextAccessor { HttpContext = httpContext });
|
|
}
|
|
|
|
private ServiceProvider BuildServices()
|
|
{
|
|
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
|
|
_tempDirectories.Add(directory);
|
|
|
|
IConfigurationRoot configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(
|
|
new Dictionary<string, string?>
|
|
{
|
|
["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
|
|
["MxGateway:ApiKeyPepper"] = "test-pepper"
|
|
})
|
|
.Build();
|
|
|
|
ServiceCollection services = new();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
services.AddGatewayConfiguration(configuration);
|
|
services.AddSqliteAuthStore(configuration);
|
|
|
|
ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
|
|
|
|
// Production migrates the schema via the migration hosted service at startup; in these
|
|
// DI-only tests no host runs, so apply the (idempotent) migration up front.
|
|
provider.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>()
|
|
.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
return provider;
|
|
}
|
|
|
|
private static Task<IReadOnlyList<ApiKeyListItem>> ListAsync(ServiceProvider services)
|
|
{
|
|
return services.GetRequiredService<IApiKeyAdminStore>().ListAsync(CancellationToken.None);
|
|
}
|
|
|
|
private static Task<IReadOnlyList<ApiKeyAuditEntry>> ListAuditAsync(ServiceProvider services)
|
|
{
|
|
return services.GetRequiredService<IApiKeyAuditStore>().ListRecentAsync(50, CancellationToken.None);
|
|
}
|
|
|
|
private static DashboardApiKeyManagementRequest CreateRequest()
|
|
{
|
|
return new DashboardApiKeyManagementRequest(
|
|
KeyId: "operator01",
|
|
DisplayName: "Operator",
|
|
Scopes: new HashSet<string>([GatewayScopes.SessionOpen], StringComparer.Ordinal),
|
|
Constraints: ApiKeyConstraints.Empty with
|
|
{
|
|
BrowseSubtrees = ["Area1/*"],
|
|
});
|
|
}
|
|
|
|
private static ClaimsPrincipal CreateAuthorizedUser()
|
|
{
|
|
// Phase 3: include ZbClaimTypes.Username so ResolveOperatorActor picks up the LDAP
|
|
// login name ("alice") as the audit Actor. The keyId ("operator01") is the Target.
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
|
new Claim(ZbClaimTypes.Username, "alice"),
|
|
],
|
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
|
ClaimTypes.Name,
|
|
ClaimTypes.Role);
|
|
|
|
return new ClaimsPrincipal(identity);
|
|
}
|
|
|
|
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
|
|
public void Dispose()
|
|
{
|
|
foreach (TempDatabaseDirectory directory in _tempDirectories)
|
|
{
|
|
directory.Dispose();
|
|
}
|
|
|
|
_tempDirectories.Clear();
|
|
}
|
|
|
|
/// <summary>In-memory <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> that records every event.</summary>
|
|
private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
|
|
{
|
|
/// <summary>Gets the recorded canonical audit events.</summary>
|
|
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = [];
|
|
|
|
/// <summary>Records the audit event in memory instead of writing it to a real sink.</summary>
|
|
/// <param name="auditEvent">The audit event to record.</param>
|
|
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
|
{
|
|
Events.Add(auditEvent);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|