mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
216 lines
8.3 KiB
C#
216 lines
8.3 KiB
C#
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;
|
|
}
|
|
}
|