544a6ddb77
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).
- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
XML docs on the public parameter surface.
Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
339 lines
12 KiB
C#
339 lines
12 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
using ZB.MOM.WW.Auth.ApiKeys;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
|
|
|
public class ApiKeyVerifierTests
|
|
{
|
|
private const string TokenPrefix = "mxgw";
|
|
private const string Pepper = "test-pepper";
|
|
private const string KeyId = "abc123";
|
|
private const string Secret = "supersecretvalue";
|
|
private const string DisplayName = "Test Key";
|
|
private const string ConstraintsJson = """{"ipAllow":["10.0.0.0/8"]}""";
|
|
|
|
private static readonly IReadOnlySet<string> Scopes =
|
|
new HashSet<string> { "read", "write" };
|
|
|
|
private static string Header(string keyId, string secret) =>
|
|
$"{TokenPrefix}_{keyId}_{secret}";
|
|
|
|
private static ApiKeyRecord BuildRecord(
|
|
byte[] secretHash,
|
|
DateTimeOffset? revokedUtc = null) => new(
|
|
KeyId: KeyId,
|
|
KeyPrefix: TokenPrefix,
|
|
SecretHash: secretHash,
|
|
DisplayName: DisplayName,
|
|
Scopes: Scopes,
|
|
ConstraintsJson: ConstraintsJson,
|
|
CreatedUtc: DateTimeOffset.UnixEpoch,
|
|
LastUsedUtc: null,
|
|
RevokedUtc: revokedUtc);
|
|
|
|
private static ApiKeyVerifier BuildVerifier(
|
|
FakeApiKeyStore store,
|
|
FakePepperProvider pepperProvider) =>
|
|
new(new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, pepperProvider);
|
|
|
|
// --- MissingOrMalformed ---
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("garbage")]
|
|
[InlineData("wrongprefix_abc123_secret")]
|
|
public async Task VerifyAsync_MissingOrMalformedHeader_ReturnsMissingOrMalformed(string? header)
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result = await verifier.VerifyAsync(header!, CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- PepperUnavailable ---
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task VerifyAsync_PepperUnavailable_ReturnsPepperUnavailable(string? pepper)
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_PepperUnavailable_DoesNotQueryStore()
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(null));
|
|
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(store.FindByKeyIdCalled);
|
|
}
|
|
|
|
// --- KeyNotFound ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_KeyNotFound_ReturnsKeyNotFound()
|
|
{
|
|
var store = new FakeApiKeyStore { Record = null };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- KeyRevoked ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_RevokedKey_ReturnsKeyRevoked()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore
|
|
{
|
|
Record = BuildRecord(hash, revokedUtc: DateTimeOffset.UtcNow),
|
|
};
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- SecretMismatch ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_WrongSecret_ReturnsSecretMismatch()
|
|
{
|
|
// Record's hash is built from a DIFFERENT secret with the test pepper.
|
|
byte[] hash = ApiKeySecretHasher.Hash("a-different-secret", Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- Success ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_ReturnsSuccessWithIdentity()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Null(result.Failure);
|
|
Assert.NotNull(result.Identity);
|
|
Assert.Equal(KeyId, result.Identity!.KeyId);
|
|
Assert.Equal(DisplayName, result.Identity.DisplayName);
|
|
Assert.Equal(Scopes, result.Identity.Scopes);
|
|
Assert.Equal(ConstraintsJson, result.Identity.Constraints);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_MarksKeyUsed()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.True(store.MarkUsedCalled);
|
|
Assert.Equal(KeyId, store.MarkUsedKeyId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_UsesInjectedTimeProviderForMarkUsed()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero));
|
|
var verifier = new ApiKeyVerifier(
|
|
new ApiKeyOptions { TokenPrefix = TokenPrefix },
|
|
store,
|
|
new FakePepperProvider(Pepper),
|
|
fakeTime);
|
|
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.Equal(fakeTime.Now, store.MarkUsedWhenUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_DoesNotLeakSecretInIdentity()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
string identityText = result.Identity!.ToString();
|
|
Assert.DoesNotContain(Secret, identityText, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(Pepper, identityText, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
|
|
}
|
|
|
|
// --- Auth-002: a failed best-effort MarkUsedAsync must NOT fail a valid key ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds()
|
|
{
|
|
// MarkUsedAsync is best-effort "last used" bookkeeping. A transient storage failure
|
|
// (SQLITE_BUSY, disk full, locked DB) must not turn an otherwise-valid credential into a
|
|
// failed auth: the decision is already made before the usage write. The verifier's contract
|
|
// is "the only exception path is cancellation", so a non-cancellation MarkUsedAsync failure
|
|
// is swallowed and the result is still Succeeded == true.
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore
|
|
{
|
|
Record = BuildRecord(hash),
|
|
MarkUsedException = new InvalidOperationException("SQLITE_BUSY"),
|
|
};
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Null(result.Failure);
|
|
Assert.NotNull(result.Identity);
|
|
Assert.Equal(KeyId, result.Identity!.KeyId);
|
|
Assert.True(store.MarkUsedCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_MarkUsedThrowsOperationCanceled_Propagates()
|
|
{
|
|
// The ONLY exception path is cancellation: an OperationCanceledException from the usage
|
|
// write (e.g. the request was cancelled mid-write) is honoured and re-thrown, not swallowed.
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore
|
|
{
|
|
Record = BuildRecord(hash),
|
|
MarkUsedException = new OperationCanceledException(),
|
|
};
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None));
|
|
}
|
|
|
|
// --- Cancellation ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_AlreadyCancelled_Throws()
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => verifier.VerifyAsync(Header(KeyId, Secret), cts.Token));
|
|
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- Bearer scheme acceptance (sanity) ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_BearerPrefixedValidKey_Succeeds()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync($"Bearer {Header(KeyId, Secret)}", CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
// --- Fakes ---
|
|
|
|
private sealed class FakeApiKeyStore : IApiKeyStore
|
|
{
|
|
public ApiKeyRecord? Record { get; set; }
|
|
public bool FindByKeyIdCalled { get; private set; }
|
|
public bool MarkUsedCalled { get; private set; }
|
|
public string? MarkUsedKeyId { get; private set; }
|
|
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
|
|
|
|
/// <summary>When set, <see cref="MarkUsedAsync"/> throws this exception (after recording the call).</summary>
|
|
public Exception? MarkUsedException { get; set; }
|
|
|
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
|
{
|
|
FindByKeyIdCalled = true;
|
|
return Task.FromResult(Record);
|
|
}
|
|
|
|
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct) =>
|
|
throw new NotSupportedException("Verifier must use FindByKeyIdAsync to discriminate revoked keys.");
|
|
|
|
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
|
{
|
|
MarkUsedCalled = true;
|
|
MarkUsedKeyId = keyId;
|
|
MarkUsedWhenUtc = whenUtc;
|
|
if (MarkUsedException is not null)
|
|
{
|
|
return Task.FromException(MarkUsedException);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
|
|
{
|
|
public string? GetPepper() => pepper;
|
|
}
|
|
|
|
private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider
|
|
{
|
|
public DateTimeOffset Now { get; } = now;
|
|
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
}
|
|
}
|