Add XML documentation across gateway, worker, and .NET client
This commit is contained in:
@@ -8,6 +8,7 @@ namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyAdminCliRunnerTests
|
||||
{
|
||||
/// <summary>Verifies that CreateKeyAsync creates an authenticating key and audits the action.</summary>
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits()
|
||||
{
|
||||
@@ -44,6 +45,7 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ListKeysAsync does not print the raw secret.</summary>
|
||||
[Fact]
|
||||
public async Task ListKeysAsync_DoesNotPrintRawSecret()
|
||||
{
|
||||
@@ -72,6 +74,7 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RevokeKeyAsync causes the revoked key to fail verification and is audited.</summary>
|
||||
[Fact]
|
||||
public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits()
|
||||
{
|
||||
@@ -105,6 +108,7 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RotateKeyAsync prints the new secret once and invalidates the old secret.</summary>
|
||||
[Fact]
|
||||
public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret()
|
||||
{
|
||||
@@ -140,6 +144,7 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Assert.True(newVerification.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CreateKeyAsync prints the raw secret exactly once.</summary>
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyAdminCommandLineParserTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies non-API key commands return not-an-API-key result.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_NonApiKeyCommand_ReturnsNotApiKeyCommand()
|
||||
{
|
||||
@@ -13,6 +16,9 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
Assert.Null(result.Command);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies API key create command parsing returns options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_ReturnsOptions()
|
||||
{
|
||||
@@ -46,6 +52,9 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
Assert.Contains("events:read", result.Command.Scopes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies create key without display name returns error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
|
||||
{
|
||||
@@ -57,6 +66,9 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
Assert.Contains("--display-name", result.Error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies key ID with underscore returns error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_KeyIdWithUnderscore_ReturnsError()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyParserTests
|
||||
{
|
||||
/// <summary>Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret.</summary>
|
||||
[Fact]
|
||||
public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret()
|
||||
{
|
||||
@@ -19,6 +20,8 @@ public sealed class ApiKeyParserTests
|
||||
Assert.Equal("secret_value", apiKey.Secret);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TryParseAuthorizationHeader returns false for malformed tokens.</summary>
|
||||
/// <param name="authorizationHeader">Malformed authorization header value.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeySecretHasherTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies identical pepper and secret produce identical hashes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HashSecret_SamePepperAndSecret_ReturnsSameHash()
|
||||
{
|
||||
@@ -19,6 +22,9 @@ public sealed class ApiKeySecretHasherTests
|
||||
Assert.NotEqual("raw-secret"u8.ToArray(), firstHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies different pepper values produce different hashes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HashSecret_DifferentPepper_ReturnsDifferentHash()
|
||||
{
|
||||
@@ -28,6 +34,9 @@ public sealed class ApiKeySecretHasherTests
|
||||
Assert.NotEqual(firstHash, secondHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies missing pepper throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HashSecret_MissingPepper_Throws()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyVerifierTests
|
||||
{
|
||||
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public sealed class ApiKeyVerifierTests
|
||||
Assert.True(store.MarkedUsed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync does not expose the raw secret in the result.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
|
||||
{
|
||||
@@ -44,6 +46,8 @@ public sealed class ApiKeyVerifierTests
|
||||
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails with unauthenticated status for a malformed key.</summary>
|
||||
/// <param name="authorizationHeader">Authorization header value to test.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("Bearer mxgw_operator01")]
|
||||
@@ -63,6 +67,7 @@ public sealed class ApiKeyVerifierTests
|
||||
Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UnknownKey_Fails()
|
||||
{
|
||||
@@ -79,6 +84,7 @@ public sealed class ApiKeyVerifierTests
|
||||
Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails for a wrong secret.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_Fails()
|
||||
{
|
||||
@@ -95,6 +101,7 @@ public sealed class ApiKeyVerifierTests
|
||||
Assert.False(store.MarkedUsed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails for a revoked key.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RevokedKey_Fails()
|
||||
{
|
||||
@@ -111,6 +118,7 @@ public sealed class ApiKeyVerifierTests
|
||||
Assert.False(store.MarkedUsed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails when the pepper is missing.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingPepper_Fails()
|
||||
{
|
||||
@@ -166,15 +174,23 @@ public sealed class ApiKeyVerifierTests
|
||||
return new ApiKeySecretHasher(configuration, Options.Create(options));
|
||||
}
|
||||
|
||||
/// <summary>Fake in-memory API key store for testing.</summary>
|
||||
private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore
|
||||
{
|
||||
/// <summary>Gets whether the key was marked as used.</summary>
|
||||
public bool MarkedUsed { get; private set; }
|
||||
|
||||
/// <summary>Finds an API key record by its ID.</summary>
|
||||
/// <param name="keyId">Identifier of the API key.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
|
||||
}
|
||||
|
||||
/// <summary>Finds an active (non-revoked) API key record by its ID.</summary>
|
||||
/// <param name="keyId">Identifier of the API key.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(
|
||||
@@ -183,6 +199,10 @@ public sealed class ApiKeyVerifierTests
|
||||
: null);
|
||||
}
|
||||
|
||||
/// <summary>Marks an API key as used at the specified time.</summary>
|
||||
/// <param name="keyId">Identifier of the API key.</param>
|
||||
/// <param name="usedUtc">Timestamp when the key was used.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
MarkedUsed = storedKey?.KeyId == keyId;
|
||||
|
||||
@@ -8,8 +8,14 @@ using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SqliteAuthStore"/>.
|
||||
/// </summary>
|
||||
public sealed class SqliteAuthStoreTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that MigrateAsync initializes the database schema.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
|
||||
{
|
||||
@@ -25,6 +31,9 @@ public sealed class SqliteAuthStoreTests
|
||||
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MigrateAsync migrates and is idempotent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently()
|
||||
{
|
||||
@@ -42,6 +51,9 @@ public sealed class SqliteAuthStoreTests
|
||||
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that gateway startup fails with a newer schema version.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_NewerSchemaVersion_BlocksStartup()
|
||||
{
|
||||
@@ -60,6 +72,9 @@ public sealed class SqliteAuthStoreTests
|
||||
Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that FindActiveByKeyIdAsync returns an active key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
|
||||
{
|
||||
@@ -80,6 +95,9 @@ public sealed class SqliteAuthStoreTests
|
||||
Assert.Null(key.RevokedUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that FindActiveByKeyIdAsync returns null for a revoked key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull()
|
||||
{
|
||||
@@ -100,6 +118,9 @@ public sealed class SqliteAuthStoreTests
|
||||
Assert.NotNull(storedKey.RevokedUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the audit store persists audit events.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent()
|
||||
{
|
||||
|
||||
+31
@@ -9,6 +9,7 @@ namespace MxGateway.Tests.Security.Authorization;
|
||||
|
||||
public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
{
|
||||
/// <summary>Verifies that missing API key returns unauthenticated status.</summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
|
||||
{
|
||||
@@ -27,6 +28,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid API key error does not expose raw credentials.</summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that valid key without required scope returns permission denied.</summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
@@ -61,6 +64,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that valid key with scope sets request identity for the handler.</summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity()
|
||||
{
|
||||
@@ -86,6 +90,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Null(identityAccessor.Current);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that server stream handler requires proper scope.</summary>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
@@ -104,6 +109,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that server stream handler allows streams with proper scope.</summary>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream()
|
||||
{
|
||||
@@ -128,6 +134,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Null(identityAccessor.Current);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disabled authentication skips API key verification.</summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
|
||||
{
|
||||
@@ -183,10 +190,16 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
|
||||
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets whether the verifier was called.</summary>
|
||||
public bool WasCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets the last authorization header seen by the verifier.</summary>
|
||||
public string? LastAuthorizationHeader { get; private set; }
|
||||
|
||||
/// <summary>Verifies the authorization header against stored result.</summary>
|
||||
/// <param name="authorizationHeader">The authorization header to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Configured verification result.</returns>
|
||||
public Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -200,10 +213,15 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
|
||||
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
{
|
||||
/// <summary>Gets messages written to the stream.</summary>
|
||||
public List<T> Messages { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets write options for the stream.</summary>
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
/// <summary>Writes a message to the stream.</summary>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <returns>Task representing the write operation.</returns>
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
Messages.Add(message);
|
||||
@@ -221,43 +239,56 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
private Status status;
|
||||
private WriteOptions? writeOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => status;
|
||||
set => status = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => writeOptions;
|
||||
set => writeOptions = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IDictionary<object, object> UserStateCore => userState;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace MxGateway.Tests.Security.Authorization;
|
||||
|
||||
public sealed class GatewayGrpcScopeResolverTests
|
||||
{
|
||||
/// <summary>Verifies that ResolveRequiredScope returns the expected scope for known RPC request types.</summary>
|
||||
/// <param name="requestType">The gRPC request type to test.</param>
|
||||
/// <param name="expectedScope">The expected scope for the request.</param>
|
||||
[Theory]
|
||||
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
|
||||
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
|
||||
@@ -25,6 +28,9 @@ public sealed class GatewayGrpcScopeResolverTests
|
||||
Assert.Equal(expectedScope, scope);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ResolveRequiredScope returns the expected scope for MXAccess invoke commands.</summary>
|
||||
/// <param name="commandKind">The MXAccess command kind to test.</param>
|
||||
/// <param name="expectedScope">The expected scope for the command.</param>
|
||||
[Theory]
|
||||
[InlineData(MxCommandKind.Register, GatewayScopes.InvokeRead)]
|
||||
[InlineData(MxCommandKind.AddItem, GatewayScopes.InvokeRead)]
|
||||
|
||||
Reference in New Issue
Block a user