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(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( It.Is(r => r.SecretId == SecretId), It.IsAny())) .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(), It.IsAny()), Times.Once); } [Fact] public async Task Unknown_key_forces_one_refresh_then_rejects_with_unknown() { var client = new Mock(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( It.Is(r => r.SecretId == SecretId), It.IsAny())) .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(), It.IsAny()), Times.Exactly(2)); } [Fact] public async Task Rotation_is_discovered_after_cache_expiry() { var call = 0; var client = new Mock(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( It.IsAny(), It.IsAny())) .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(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( It.IsAny(), It.IsAny())) .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(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( It.IsAny(), It.IsAny())) .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(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( It.IsAny(), It.IsAny())) .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(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.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 { public TestOptionsMonitor(WorkOrderWebhookOptions currentValue) { CurrentValue = currentValue; } public WorkOrderWebhookOptions CurrentValue { get; } public WorkOrderWebhookOptions Get(string? name) => CurrentValue; public IDisposable? OnChange( Action listener) => null; } }