Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
T

286 lines
9.9 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);
}
// --- 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; }
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;
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;
}
}