shoc-backend/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs

217 lines
8.3 KiB
C#
Raw Normal View History

using System.Text.Json;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class WorkOrderWebhookSecretProviderTests
{
private const string SecretId = "workorder-ingest/shoc-webhook-hmac";
private static readonly string CurrentKeyset = KeysetDocument(
("2026-07-20T00", Hex(64, 'a')));
private static readonly string RotatedKeyset = KeysetDocument(
("2026-08-20T00", Hex(64, 'b')),
("2026-07-20T00", Hex(64, 'a')));
[Fact]
public async Task Known_key_is_cached_and_callers_receive_independent_secret_copies()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.Is<GetSecretValueRequest>(r => r.SecretId == SecretId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse(CurrentKeyset));
var provider = CreateProvider(client.Object, SecretId);
var first = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Found, first.Status);
Assert.NotNull(first.Secret);
first.Secret![0] = 0;
var second = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Found, second.Status);
Assert.Equal(Hex(64, 'a'), EncodingText(second.Secret!));
client.Verify(
c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Unknown_key_forces_one_refresh_then_rejects_with_unknown()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.Is<GetSecretValueRequest>(r => r.SecretId == SecretId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse(CurrentKeyset));
var provider = CreateProvider(client.Object, SecretId);
// Prime the cache with a known key first so the second lookup exercises
// the refresh-on-unknown path.
await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
var result = await provider.GetSecretAsync(
"attacker-controlled-key",
CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status);
// One prime + exactly one refresh on the unknown kid.
client.Verify(
c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
[Fact]
public async Task Rotation_is_discovered_after_cache_expiry()
{
var call = 0;
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => ++call == 1 ? SecretResponse(CurrentKeyset) : SecretResponse(RotatedKeyset));
var provider = CreateProvider(client.Object, SecretId, cacheSeconds: 1);
var before = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(Hex(64, 'a'), EncodingText(before.Secret!));
await Task.Delay(TimeSpan.FromMilliseconds(1100));
var after = await provider.GetSecretAsync("2026-08-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Found, after.Status);
Assert.Equal(Hex(64, 'b'), EncodingText(after.Secret!));
}
[Fact]
public async Task Malformed_keyset_returns_unavailable()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse("not-json"));
var provider = CreateProvider(client.Object, SecretId);
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
Assert.Null(result.Secret);
}
[Fact]
public async Task Duplicate_kid_returns_unavailable()
{
var duplicate = KeysetDocument(
("2026-07-20T00", Hex(64, 'a')),
("2026-07-20T00", Hex(64, 'b')));
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse(duplicate));
var provider = CreateProvider(client.Object, SecretId);
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
}
[Fact]
public async Task Aws_failure_returns_unavailable_without_exposing_the_exception()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("sensitive provider detail"));
var provider = CreateProvider(client.Object, SecretId);
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
Assert.Null(result.Secret);
}
[Fact]
public async Task Empty_secret_id_does_not_invoke_aws_and_returns_unavailable()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
var provider = CreateProvider(client.Object, secretId: "");
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
client.VerifyNoOtherCalls();
}
private static AwsWorkOrderWebhookSecretProvider CreateProvider(
IAmazonSecretsManager client,
string secretId,
int cacheSeconds = 300) =>
new(
client,
new TestOptionsMonitor(new WorkOrderWebhookOptions
{
SecretCacheSeconds = cacheSeconds,
SecretId = secretId
}),
TimeProvider.System,
NullLogger<AwsWorkOrderWebhookSecretProvider>.Instance);
private static GetSecretValueResponse SecretResponse(string secretString) =>
new() { SecretString = secretString };
private static string KeysetDocument(params (string Kid, string Secret)[] keys)
{
using var buffer = new MemoryStream();
using var writer = new Utf8JsonWriter(buffer);
writer.WriteStartObject();
writer.WritePropertyName("keys");
writer.WriteStartArray();
foreach (var (kid, secret) in keys)
{
writer.WriteStartObject();
writer.WriteString("kid", kid);
writer.WriteString("secret", secret);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return System.Text.Encoding.UTF8.GetString(buffer.ToArray());
}
private static string Hex(int length, char digit) => new(digit, length);
private static string EncodingText(byte[] secret) =>
System.Text.Encoding.UTF8.GetString(secret);
private sealed class TestOptionsMonitor : IOptionsMonitor<WorkOrderWebhookOptions>
{
public TestOptionsMonitor(WorkOrderWebhookOptions currentValue)
{
CurrentValue = currentValue;
}
public WorkOrderWebhookOptions CurrentValue { get; }
public WorkOrderWebhookOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderWebhookOptions, string?> listener) => null;
}
}