mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
fix(work-orders): satisfy producer contract review
This commit is contained in:
parent
27bf81b7f8
commit
f701899a83
15 changed files with 639 additions and 109 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Amazon.SecretsManager;
|
using Amazon.SecretsManager;
|
||||||
using Amazon.SecretsManager.Model;
|
using Amazon.SecretsManager.Model;
|
||||||
using Api.SeaHavenIndustries.Infrastructure;
|
using Api.SeaHavenIndustries.Infrastructure;
|
||||||
|
|
@ -12,23 +13,33 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public sealed class WorkOrderWebhookSecretProviderTests
|
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]
|
[Fact]
|
||||||
public async Task Known_key_is_cached_and_callers_receive_independent_secret_copies()
|
public async Task Known_key_is_cached_and_callers_receive_independent_secret_copies()
|
||||||
{
|
{
|
||||||
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
|
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
|
||||||
client.Setup(c => c.GetSecretValueAsync(
|
client.Setup(c => c.GetSecretValueAsync(
|
||||||
It.Is<GetSecretValueRequest>(r => r.SecretId == "arn:configured"),
|
It.Is<GetSecretValueRequest>(r => r.SecretId == SecretId),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new GetSecretValueResponse { SecretString = "shared-secret" });
|
.ReturnsAsync(SecretResponse(CurrentKeyset));
|
||||||
var provider = CreateProvider(client.Object);
|
var provider = CreateProvider(client.Object, SecretId);
|
||||||
|
|
||||||
var first = await provider.GetSecretAsync("current", CancellationToken.None);
|
var first = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
|
||||||
|
Assert.Equal(WorkOrderWebhookSecretStatus.Found, first.Status);
|
||||||
|
Assert.NotNull(first.Secret);
|
||||||
first.Secret![0] = 0;
|
first.Secret![0] = 0;
|
||||||
var second = await provider.GetSecretAsync("current", CancellationToken.None);
|
|
||||||
|
var second = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WorkOrderWebhookSecretStatus.Found, second.Status);
|
Assert.Equal(WorkOrderWebhookSecretStatus.Found, second.Status);
|
||||||
Assert.Equal("shared-secret", System.Text.Encoding.UTF8.GetString(second.Secret!));
|
Assert.Equal(Hex(64, 'a'), EncodingText(second.Secret!));
|
||||||
client.VerifyAll();
|
|
||||||
client.Verify(
|
client.Verify(
|
||||||
c => c.GetSecretValueAsync(
|
c => c.GetSecretValueAsync(
|
||||||
It.IsAny<GetSecretValueRequest>(),
|
It.IsAny<GetSecretValueRequest>(),
|
||||||
|
|
@ -37,17 +48,85 @@ public sealed class WorkOrderWebhookSecretProviderTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Unknown_key_never_influences_an_aws_request()
|
public async Task Unknown_key_forces_one_refresh_then_rejects_with_unknown()
|
||||||
{
|
{
|
||||||
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
|
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
|
||||||
var provider = CreateProvider(client.Object);
|
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(
|
var result = await provider.GetSecretAsync(
|
||||||
"attacker-controlled-key",
|
"attacker-controlled-key",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status);
|
Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status);
|
||||||
client.VerifyNoOtherCalls();
|
// 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]
|
[Fact]
|
||||||
|
|
@ -58,29 +137,68 @@ public sealed class WorkOrderWebhookSecretProviderTests
|
||||||
It.IsAny<GetSecretValueRequest>(),
|
It.IsAny<GetSecretValueRequest>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new InvalidOperationException("sensitive provider detail"));
|
.ThrowsAsync(new InvalidOperationException("sensitive provider detail"));
|
||||||
var provider = CreateProvider(client.Object);
|
var provider = CreateProvider(client.Object, SecretId);
|
||||||
|
|
||||||
var result = await provider.GetSecretAsync("current", CancellationToken.None);
|
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
|
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
|
||||||
Assert.Null(result.Secret);
|
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(
|
private static AwsWorkOrderWebhookSecretProvider CreateProvider(
|
||||||
IAmazonSecretsManager client) =>
|
IAmazonSecretsManager client,
|
||||||
|
string secretId,
|
||||||
|
int cacheSeconds = 300) =>
|
||||||
new(
|
new(
|
||||||
client,
|
client,
|
||||||
new TestOptionsMonitor(new WorkOrderWebhookOptions
|
new TestOptionsMonitor(new WorkOrderWebhookOptions
|
||||||
{
|
{
|
||||||
SecretCacheSeconds = 300,
|
SecretCacheSeconds = cacheSeconds,
|
||||||
KeySecrets = new Dictionary<string, string>
|
SecretId = secretId
|
||||||
{
|
|
||||||
["current"] = "arn:configured"
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
NullLogger<AwsWorkOrderWebhookSecretProvider>.Instance);
|
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>
|
private sealed class TestOptionsMonitor : IOptionsMonitor<WorkOrderWebhookOptions>
|
||||||
{
|
{
|
||||||
public TestOptionsMonitor(WorkOrderWebhookOptions currentValue)
|
public TestOptionsMonitor(WorkOrderWebhookOptions currentValue)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Amazon.SecretsManager;
|
using Amazon.SecretsManager;
|
||||||
using Amazon.SecretsManager.Model;
|
using Amazon.SecretsManager.Model;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -12,14 +12,17 @@ namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
{
|
{
|
||||||
public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider
|
public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider
|
||||||
{
|
{
|
||||||
|
private const int MaxKeys = 16;
|
||||||
|
private const int MaxKidLength = 128;
|
||||||
|
private const int SecretHexLength = 64;
|
||||||
|
private const int MaxSecretDocumentLength = 32 * 1024;
|
||||||
|
|
||||||
private readonly IAmazonSecretsManager _client;
|
private readonly IAmazonSecretsManager _client;
|
||||||
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
|
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ILogger<AwsWorkOrderWebhookSecretProvider> _logger;
|
private readonly ILogger<AwsWorkOrderWebhookSecretProvider> _logger;
|
||||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache =
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||||
new(StringComparer.Ordinal);
|
private CachedKeyset? _cache;
|
||||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks =
|
|
||||||
new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public AwsWorkOrderWebhookSecretProvider(
|
public AwsWorkOrderWebhookSecretProvider(
|
||||||
IAmazonSecretsManager client,
|
IAmazonSecretsManager client,
|
||||||
|
|
@ -37,81 +40,208 @@ namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
string keyId,
|
string keyId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var options = _options.CurrentValue;
|
if (string.IsNullOrWhiteSpace(keyId) || keyId.Length > MaxKidLength)
|
||||||
if (!options.KeySecrets.TryGetValue(keyId, out var secretArn)
|
|
||||||
|| string.IsNullOrWhiteSpace(secretArn))
|
|
||||||
{
|
|
||||||
if (_cache.TryRemove(keyId, out var removed))
|
|
||||||
CryptographicOperations.ZeroMemory(removed.Secret);
|
|
||||||
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
|
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
|
||||||
}
|
|
||||||
|
|
||||||
|
var observed = _cache;
|
||||||
var now = _timeProvider.GetUtcNow();
|
var now = _timeProvider.GetUtcNow();
|
||||||
if (_cache.TryGetValue(keyId, out var cached)
|
if (observed != null && observed.ExpiresAt > now)
|
||||||
&& cached.SecretArn == secretArn
|
|
||||||
&& cached.ExpiresAt > now)
|
|
||||||
return FoundCopy(cached.Secret);
|
|
||||||
|
|
||||||
var keyLock = _locks.GetOrAdd(keyId, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await keyLock.WaitAsync(cancellationToken);
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
now = _timeProvider.GetUtcNow();
|
var match = observed.Lookup(keyId);
|
||||||
if (_cache.TryGetValue(keyId, out cached)
|
if (match != null)
|
||||||
&& cached.SecretArn == secretArn
|
return FoundCopy(match);
|
||||||
&& cached.ExpiresAt > now)
|
|
||||||
return FoundCopy(cached.Secret);
|
|
||||||
|
|
||||||
var response = await _client.GetSecretValueAsync(
|
|
||||||
new GetSecretValueRequest { SecretId = secretArn },
|
|
||||||
cancellationToken);
|
|
||||||
var secret = ReadSecret(response);
|
|
||||||
if (secret.Length == 0)
|
|
||||||
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
|
|
||||||
|
|
||||||
var ttl = TimeSpan.FromSeconds(Math.Clamp(options.SecretCacheSeconds, 1, 3600));
|
|
||||||
var replacement = new CacheEntry(secretArn, secret, now.Add(ttl));
|
|
||||||
_cache.AddOrUpdate(
|
|
||||||
keyId,
|
|
||||||
replacement,
|
|
||||||
(_, prior) =>
|
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(prior.Secret);
|
|
||||||
return replacement;
|
|
||||||
});
|
|
||||||
return FoundCopy(secret);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
var refresh = await RefreshAsync(observed, cancellationToken);
|
||||||
throw;
|
if (refresh.Status == RefreshStatus.Unavailable)
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// AWS exceptions can contain the configured secret ARN. Keep
|
|
||||||
// diagnostics intentionally generic for this secret-bearing path.
|
|
||||||
_logger.LogError("Work-order webhook signing secret retrieval failed.");
|
|
||||||
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
|
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
keyLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] ReadSecret(GetSecretValueResponse response)
|
var keyset = refresh.Keyset;
|
||||||
{
|
var lookup = keyset?.Lookup(keyId);
|
||||||
if (!string.IsNullOrEmpty(response.SecretString))
|
if (lookup != null)
|
||||||
return Encoding.UTF8.GetBytes(response.SecretString);
|
return FoundCopy(lookup);
|
||||||
|
|
||||||
return response.SecretBinary?.ToArray() ?? Array.Empty<byte>();
|
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
|
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
|
||||||
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
|
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
|
||||||
|
|
||||||
private sealed record CacheEntry(
|
private async Task<RefreshResult> RefreshAsync(
|
||||||
string SecretArn,
|
CachedKeyset? observed,
|
||||||
byte[] Secret,
|
CancellationToken cancellationToken)
|
||||||
DateTimeOffset ExpiresAt);
|
{
|
||||||
|
var secretId = _options.CurrentValue.SecretId;
|
||||||
|
if (string.IsNullOrWhiteSpace(secretId))
|
||||||
|
return new RefreshResult(RefreshStatus.Unavailable, null);
|
||||||
|
|
||||||
|
await _refreshLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_cache, observed))
|
||||||
|
return new RefreshResult(RefreshStatus.Refreshed, _cache);
|
||||||
|
|
||||||
|
CachedKeyset? replacement;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.GetSecretValueAsync(
|
||||||
|
new GetSecretValueRequest { SecretId = secretId },
|
||||||
|
cancellationToken);
|
||||||
|
replacement = ParseKeyset(response);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
_logger.LogError("Work-order webhook signing secret retrieval failed.");
|
||||||
|
return new RefreshResult(RefreshStatus.Unavailable, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacement == null)
|
||||||
|
return new RefreshResult(RefreshStatus.Unavailable, null);
|
||||||
|
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
var ttlSeconds = Math.Clamp(options.SecretCacheSeconds, 1, 300);
|
||||||
|
replacement.SetExpiry(_timeProvider.GetUtcNow().AddSeconds(ttlSeconds));
|
||||||
|
ReplaceCache(observed, replacement);
|
||||||
|
return new RefreshResult(RefreshStatus.Refreshed, replacement);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplaceCache(CachedKeyset? prior, CachedKeyset replacement)
|
||||||
|
{
|
||||||
|
if (Interlocked.CompareExchange(ref _cache, replacement, prior) == prior)
|
||||||
|
{
|
||||||
|
if (prior != null)
|
||||||
|
prior.Zero();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
replacement.Zero();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum RefreshStatus
|
||||||
|
{
|
||||||
|
Refreshed,
|
||||||
|
Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct RefreshResult(RefreshStatus Status, CachedKeyset? Keyset);
|
||||||
|
|
||||||
|
private static CachedKeyset? ParseKeyset(GetSecretValueResponse response)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(response.SecretString)
|
||||||
|
|| response.SecretString.Length > MaxSecretDocumentLength)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Dictionary<string, byte[]>? byKid = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(response.SecretString);
|
||||||
|
if (!document.RootElement.TryGetProperty("keys", out var keysElement)
|
||||||
|
|| keysElement.ValueKind != JsonValueKind.Array)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
byKid = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||||
|
foreach (var item in keysElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (byKid.Count >= MaxKeys)
|
||||||
|
return InvalidKeyset(byKid);
|
||||||
|
if (item.ValueKind != JsonValueKind.Object)
|
||||||
|
return InvalidKeyset(byKid);
|
||||||
|
if (!item.TryGetProperty("kid", out var kidElement)
|
||||||
|
|| kidElement.ValueKind != JsonValueKind.String)
|
||||||
|
return InvalidKeyset(byKid);
|
||||||
|
if (!item.TryGetProperty("secret", out var secretElement)
|
||||||
|
|| secretElement.ValueKind != JsonValueKind.String)
|
||||||
|
return InvalidKeyset(byKid);
|
||||||
|
|
||||||
|
var kid = kidElement.GetString();
|
||||||
|
var secret = secretElement.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(kid)
|
||||||
|
|| kid.Length > MaxKidLength
|
||||||
|
|| secret == null
|
||||||
|
|| secret.Length != SecretHexLength
|
||||||
|
|| !IsAsciiHex(secret))
|
||||||
|
return InvalidKeyset(byKid);
|
||||||
|
|
||||||
|
if (byKid.ContainsKey(kid))
|
||||||
|
return InvalidKeyset(byKid);
|
||||||
|
byKid.Add(kid, Encoding.UTF8.GetBytes(secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
if (byKid != null)
|
||||||
|
ZeroSecrets(byKid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byKid.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new CachedKeyset(byKid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CachedKeyset? InvalidKeyset(Dictionary<string, byte[]> byKid)
|
||||||
|
{
|
||||||
|
ZeroSecrets(byKid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ZeroSecrets(Dictionary<string, byte[]> byKid)
|
||||||
|
{
|
||||||
|
foreach (var secret in byKid.Values)
|
||||||
|
CryptographicOperations.ZeroMemory(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAsciiHex(string value)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
var c = value[i];
|
||||||
|
if (!IsHexDigit(c))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHexDigit(char c) =>
|
||||||
|
(uint)(c - '0') <= 9u
|
||||||
|
|| (uint)(c - 'a') <= 5u
|
||||||
|
|| (uint)(c - 'A') <= 5u;
|
||||||
|
|
||||||
|
private sealed class CachedKeyset
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, byte[]> _byKid;
|
||||||
|
private DateTimeOffset _expiresAt;
|
||||||
|
|
||||||
|
public DateTimeOffset ExpiresAt => _expiresAt;
|
||||||
|
|
||||||
|
public CachedKeyset(Dictionary<string, byte[]> byKid)
|
||||||
|
{
|
||||||
|
_byKid = byKid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetExpiry(DateTimeOffset expiresAt) => _expiresAt = expiresAt;
|
||||||
|
|
||||||
|
public byte[]? Lookup(string kid)
|
||||||
|
{
|
||||||
|
_byKid.TryGetValue(kid, out var secret);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Zero()
|
||||||
|
{
|
||||||
|
ZeroSecrets(_byKid);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"AllowedClockSkewSeconds": 300,
|
"AllowedClockSkewSeconds": 300,
|
||||||
"SecretCacheSeconds": 300,
|
"SecretCacheSeconds": 300,
|
||||||
"Region": "",
|
"Region": "",
|
||||||
"KeySecrets": {}
|
"SecretId": "workorder-ingest/shoc-webhook-hmac"
|
||||||
},
|
},
|
||||||
"WorkOrderReconciliation": {
|
"WorkOrderReconciliation": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
namespace Data.SeaHavenIndustries.Migrations
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")]
|
||||||
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
|
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -197,10 +198,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
if (_context.Database.IsSqlServer())
|
if (_context.Database.IsSqlServer())
|
||||||
{
|
{
|
||||||
return await _context.Database
|
return await AllocateSqlServerInternalNumberAsync(cancellationToken);
|
||||||
.SqlQueryRaw<long>(
|
|
||||||
"SELECT NEXT VALUE FOR dbo.WorkOrderInternalNumberSequence AS [Value]")
|
|
||||||
.SingleAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await InMemoryNumberLock.WaitAsync(cancellationToken);
|
await InMemoryNumberLock.WaitAsync(cancellationToken);
|
||||||
|
|
@ -222,6 +220,35 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<long> AllocateSqlServerInternalNumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var connection = _context.Database.GetDbConnection();
|
||||||
|
var openedHere = false;
|
||||||
|
if (connection.State != System.Data.ConnectionState.Open)
|
||||||
|
{
|
||||||
|
await _context.Database.OpenConnectionAsync(cancellationToken);
|
||||||
|
openedHere = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT NEXT VALUE FOR dbo.WorkOrderInternalNumberSequence";
|
||||||
|
var currentTransaction = _context.Database.CurrentTransaction;
|
||||||
|
if (currentTransaction != null)
|
||||||
|
{
|
||||||
|
command.Transaction = currentTransaction.GetDbTransaction();
|
||||||
|
}
|
||||||
|
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (openedHere)
|
||||||
|
await _context.Database.CloseConnectionAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> UpsertReceiptAsync(
|
private async Task<bool> UpsertReceiptAsync(
|
||||||
string source,
|
string source,
|
||||||
string kind,
|
string kind,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ namespace SeaHaven.Services.Configuration
|
||||||
public int AllowedClockSkewSeconds { get; set; } = 300;
|
public int AllowedClockSkewSeconds { get; set; } = 300;
|
||||||
public int SecretCacheSeconds { get; set; } = 300;
|
public int SecretCacheSeconds { get; set; } = 300;
|
||||||
public string? Region { get; set; }
|
public string? Region { get; set; }
|
||||||
public Dictionary<string, string> KeySecrets { get; set; } =
|
public string? SecretId { get; set; }
|
||||||
new(StringComparer.Ordinal);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
SeaHaven.Services/Constants/WorkOrderSourceIdentity.cs
Normal file
9
SeaHaven.Services/Constants/WorkOrderSourceIdentity.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.Services.Constants
|
||||||
|
{
|
||||||
|
public static class WorkOrderSourceIdentity
|
||||||
|
{
|
||||||
|
public const string WireSource = "procurement-ingest/workorder-shoc-emitter";
|
||||||
|
|
||||||
|
public const string CanonicalSource = "procurement";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,15 +24,11 @@ namespace SeaHaven.Services.DependencyInjection
|
||||||
.Validate(
|
.Validate(
|
||||||
o => o.MaxBodyBytes is > 0 and <= 1_048_576
|
o => o.MaxBodyBytes is > 0 and <= 1_048_576
|
||||||
&& o.AllowedClockSkewSeconds is >= 0 and <= 3600
|
&& o.AllowedClockSkewSeconds is >= 0 and <= 3600
|
||||||
&& o.SecretCacheSeconds is > 0 and <= 3600,
|
&& o.SecretCacheSeconds is > 0 and <= 300,
|
||||||
"WorkOrderWebhook size, clock-skew, and cache settings are out of range.")
|
"WorkOrderWebhook size, clock-skew, and cache settings are out of range.")
|
||||||
.Validate(
|
.Validate(
|
||||||
o => !o.Enabled || (o.KeySecrets.Count > 0
|
o => !o.Enabled || !string.IsNullOrWhiteSpace(o.SecretId),
|
||||||
&& o.KeySecrets.All(kvp =>
|
"WorkOrderWebhook requires a non-empty SecretId when enabled.")
|
||||||
!string.IsNullOrWhiteSpace(kvp.Key)
|
|
||||||
&& kvp.Key.Length <= 128
|
|
||||||
&& !string.IsNullOrWhiteSpace(kvp.Value))),
|
|
||||||
"WorkOrderWebhook requires a non-empty key ID to secret ARN map when enabled.")
|
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
services.AddOptions<WorkOrderReconciliationOptions>()
|
services.AddOptions<WorkOrderReconciliationOptions>()
|
||||||
.Bind(configuration.GetSection(WorkOrderReconciliationOptions.SectionName))
|
.Bind(configuration.GetSection(WorkOrderReconciliationOptions.SectionName))
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
|
@ -13,7 +14,6 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService, IWorkOrderReconciliationRunner
|
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService, IWorkOrderReconciliationRunner
|
||||||
{
|
{
|
||||||
private const string Source = "procurement";
|
|
||||||
private readonly IProcurementWorkOrderClient _client;
|
private readonly IProcurementWorkOrderClient _client;
|
||||||
private readonly IWorkOrderWebhookDataService _workOrders;
|
private readonly IWorkOrderWebhookDataService _workOrders;
|
||||||
private readonly IWorkOrderReconciliationDataService _jobs;
|
private readonly IWorkOrderReconciliationDataService _jobs;
|
||||||
|
|
@ -260,7 +260,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
VersionHash = hash,
|
VersionHash = hash,
|
||||||
ExternalWorkOrderId = item.WorkOrderId,
|
ExternalWorkOrderId = item.WorkOrderId,
|
||||||
WorkerOrderNumber = item.WorkOrderId,
|
WorkerOrderNumber = item.WorkOrderId,
|
||||||
Source = Source,
|
Source = WorkOrderSourceIdentity.CanonicalSource,
|
||||||
IsStateEvent = true,
|
IsStateEvent = true,
|
||||||
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
|
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
|
||||||
Description = item.Description,
|
Description = item.Description,
|
||||||
|
|
@ -305,7 +305,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
VersionHash = hash,
|
VersionHash = hash,
|
||||||
ExternalWorkOrderId = item.WorkOrderId,
|
ExternalWorkOrderId = item.WorkOrderId,
|
||||||
WorkerOrderNumber = item.WorkOrderId,
|
WorkerOrderNumber = item.WorkOrderId,
|
||||||
Source = Source,
|
Source = WorkOrderSourceIdentity.CanonicalSource,
|
||||||
IsStateEvent = false,
|
IsStateEvent = false,
|
||||||
CommentId = item.CommentId,
|
CommentId = item.CommentId,
|
||||||
CommentText = item.Text,
|
CommentText = item.Text,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
|
@ -246,7 +247,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|| string.IsNullOrWhiteSpace(envelope.EventType)
|
|| string.IsNullOrWhiteSpace(envelope.EventType)
|
||||||
|| envelope.EventType.Length > 64
|
|| envelope.EventType.Length > 64
|
||||||
|| envelope.OccurredAt == null
|
|| envelope.OccurredAt == null
|
||||||
|| !string.Equals(envelope.Source, "procurement", StringComparison.Ordinal)
|
|| !string.Equals(envelope.Source, WorkOrderSourceIdentity.WireSource, StringComparison.Ordinal)
|
||||||
|| envelope.Replay == null
|
|| envelope.Replay == null
|
||||||
|| envelope.Data == null
|
|| envelope.Data == null
|
||||||
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
|
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
|
||||||
|
|
@ -306,7 +307,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
BodySha256 = bodyHash,
|
BodySha256 = bodyHash,
|
||||||
ExternalWorkOrderId = envelope.Data.WorkOrderId,
|
ExternalWorkOrderId = envelope.Data.WorkOrderId,
|
||||||
WorkerOrderNumber = envelope.Data.WorkOrderId,
|
WorkerOrderNumber = envelope.Data.WorkOrderId,
|
||||||
Source = envelope.Source!,
|
Source = WorkOrderSourceIdentity.CanonicalSource,
|
||||||
UpdatedAt = updatedAt.Value,
|
UpdatedAt = updatedAt.Value,
|
||||||
VersionHash = versionHash,
|
VersionHash = versionHash,
|
||||||
IsStateEvent = !isComment,
|
IsStateEvent = !isComment,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"_readme": "Shared HMAC signing test vectors for the SHOC work-order webhook (docs/shoc-webhook-contract.md section 6). string_to_sign = \"{timestamp}.{raw_body}\" computed over the RAW UTF-8 body bytes; signature = lowercase hex of HMAC-SHA256(secret, string_to_sign), sent as header X-SH-Signature: \"v1=<hex>\" alongside X-SH-Timestamp: <unix seconds> and X-SH-Key-Id: <kid>. The HMAC key is the UTF-8 bytes of the 64-hex 'secret' string exactly as stored in the workorder-ingest/shoc-webhook-hmac secret (NO hex-decoding on either side). 'body' is the exact raw JSON string to sign, byte-for-byte: vector 2 contains non-ASCII UTF-8 (multi-byte characters must be signed as their UTF-8 bytes), vector 3 is an empty JSON object. Producer pins: lambdas/wo/shoc_emitter/delivery.py sign_body and scripts/replay_shoc_webhooks.py sign_body, both enforced by tests/test_shoc_emitter_delivery.py. The SHOC receiver should verify its implementation against every vector before activation.",
|
||||||
|
"vectors": [
|
||||||
|
{
|
||||||
|
"kid": "2026-07-20T00",
|
||||||
|
"secret_hex": "3afc6cf9cc5782b304fda7efa7f0a77c4b67ab7336536daa228960dae34aa2fe",
|
||||||
|
"timestamp": 1784642602,
|
||||||
|
"body": "{\"schema_version\": 1, \"delivery_id\": \"f2a9c1de-7b34-4d5c-9e01-8a6b5c4d3e2f\", \"event_type\": \"work_order.created\", \"occurred_at\": \"2026-07-16T14:03:22.114208+00:00\", \"source\": \"procurement-ingest/workorder-shoc-emitter\", \"replay\": false, \"data\": {\"work_order_id\": \"11144580730\", \"wo_status\": \"new\", \"description\": \"Dock door 14 won't close\", \"customer\": \"AMAZON\", \"site_code\": \"JFK8\", \"building\": \"JFK8\", \"address\": \"546 Gulf Ave, Staten Island, NY 10314\", \"severity\": \"3-Normal\", \"priority\": \"Medium\", \"assigned_to\": \"Sea Haven Industries\", \"date_reported\": \"2026-07-14T09:12:00\", \"scheduled_start\": null, \"due_date\": null, \"record_type\": \"new_work_order\", \"created_at\": \"2026-07-16T14:03:22.114208+00:00\", \"updated_at\": \"2026-07-16T14:03:22.114208+00:00\"}}",
|
||||||
|
"expected_signature": "4e6e171480e80eed333c966743c9da46595df86b4b65b76926e0781d2d3b0b46"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kid": "2026-07-20T00",
|
||||||
|
"secret_hex": "3afc6cf9cc5782b304fda7efa7f0a77c4b67ab7336536daa228960dae34aa2fe",
|
||||||
|
"timestamp": 1784642700,
|
||||||
|
"body": "{\"schema_version\": 1, \"delivery_id\": \"0d1e2f3a-4b5c-6d7e-8f90-a1b2c3d4e5f6\", \"event_type\": \"work_order.comment_added\", \"occurred_at\": \"2026-07-16T14:05:00+00:00\", \"source\": \"procurement-ingest/workorder-shoc-emitter\", \"replay\": true, \"data\": {\"work_order_id\": \"11144580730\", \"comment_id\": \"11144580730#2026-04-27T23:51:48#a1b2c3d4e5f6\", \"record_type\": \"comment\", \"commenter\": \"APM Technician\", \"text\": \"Vendor dispatched — café access via süd door ✓\", \"created_at\": \"2026-04-27T23:51:48\", \"ingested_at\": \"2026-07-16T14:05:00+00:00\"}}",
|
||||||
|
"expected_signature": "6b61d5ac09bbf032b1a9c9b651bd7d5ea2ec925f94a857eeade52995801479e5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kid": "2026-06-20T00",
|
||||||
|
"secret_hex": "737719b2c437aa252a0db5f65411f828f244d403f7e625f7336da10ee985b2e4",
|
||||||
|
"timestamp": 1784000000,
|
||||||
|
"body": "{}",
|
||||||
|
"expected_signature": "ebe65980194b494c8de8d40cd6b8a42ff64c26749cf40fc6f36f59426dafa3c7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kid": "2026-06-20T00",
|
||||||
|
"secret_hex": "737719b2c437aa252a0db5f65411f828f244d403f7e625f7336da10ee985b2e4",
|
||||||
|
"timestamp": 1784650000,
|
||||||
|
"body": "{\"schema_version\": 1, \"event_type\": \"work_order.updated\", \"data\": {\"work_order_id\": \"999\"}}",
|
||||||
|
"expected_signature": "626c5d241887c6186fe021907b1a67a0f14ff2286d19df97785a33994c56ca54"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -28,4 +28,10 @@
|
||||||
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\shoc-webhook-test-vectors.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
151
SeaHavenIndustries.Tests/ShocWebhookVectorTests.cs
Normal file
151
SeaHavenIndustries.Tests/ShocWebhookVectorTests.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Api.SeaHavenIndustries.Infrastructure;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class ShocWebhookVectorTests
|
||||||
|
{
|
||||||
|
private const string FixturePath = "Fixtures/shoc-webhook-test-vectors.json";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task All_four_shared_signing_vectors_pass_signature_verification()
|
||||||
|
{
|
||||||
|
var vectors = await LoadVectorsAsync();
|
||||||
|
Assert.True(vectors.Count >= 4, "fixture must ship at least the four shared vectors");
|
||||||
|
|
||||||
|
foreach (var vector in vectors)
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes(vector.Body);
|
||||||
|
var timestamp = vector.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var signature = "v1=" + vector.ExpectedSignature;
|
||||||
|
var at = DateTimeOffset.FromUnixTimeSeconds(vector.Timestamp);
|
||||||
|
|
||||||
|
var data = new RecordingDataService();
|
||||||
|
var service = new WorkOrderWebhookService(
|
||||||
|
new VectorSecretProvider(vector.Kid, vector.SecretHex),
|
||||||
|
data,
|
||||||
|
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
AllowedClockSkewSeconds = 300,
|
||||||
|
SecretId = "workorder-ingest/shoc-webhook-hmac"
|
||||||
|
}),
|
||||||
|
new FixedTimeProvider(at),
|
||||||
|
NullLogger<WorkOrderWebhookService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.ProcessAsync(
|
||||||
|
new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
result.Status != WorkOrderWebhookStatus.Unauthorized,
|
||||||
|
$"vector {vector.Kid}@{vector.Timestamp} failed signature verification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Valid_envelope_vectors_are_applied_and_invalid_body_vectors_are_invalid_envelope()
|
||||||
|
{
|
||||||
|
var vectors = await LoadVectorsAsync();
|
||||||
|
var byIndex = vectors.Take(4).ToList();
|
||||||
|
|
||||||
|
var applied = await RunVectorAsync(byIndex[0]);
|
||||||
|
Assert.Equal(WorkOrderWebhookStatus.Applied, applied.Status);
|
||||||
|
|
||||||
|
var comment = await RunVectorAsync(byIndex[1]);
|
||||||
|
Assert.Equal(WorkOrderWebhookStatus.Applied, comment.Status);
|
||||||
|
|
||||||
|
var empty = await RunVectorAsync(byIndex[2]);
|
||||||
|
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, empty.Status);
|
||||||
|
|
||||||
|
var noSource = await RunVectorAsync(byIndex[3]);
|
||||||
|
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, noSource.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<WorkOrderWebhookResult> RunVectorAsync(Vector vector)
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes(vector.Body);
|
||||||
|
var timestamp = vector.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var signature = "v1=" + vector.ExpectedSignature;
|
||||||
|
var at = DateTimeOffset.FromUnixTimeSeconds(vector.Timestamp);
|
||||||
|
|
||||||
|
var data = new RecordingDataService();
|
||||||
|
var service = new WorkOrderWebhookService(
|
||||||
|
new VectorSecretProvider(vector.Kid, vector.SecretHex),
|
||||||
|
data,
|
||||||
|
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
AllowedClockSkewSeconds = 300,
|
||||||
|
SecretId = "workorder-ingest/shoc-webhook-hmac"
|
||||||
|
}),
|
||||||
|
new FixedTimeProvider(at),
|
||||||
|
NullLogger<WorkOrderWebhookService>.Instance);
|
||||||
|
|
||||||
|
return await service.ProcessAsync(
|
||||||
|
new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<Vector>> LoadVectorsAsync()
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(FixturePath);
|
||||||
|
var document = await JsonDocument.ParseAsync(stream);
|
||||||
|
var result = new List<Vector>();
|
||||||
|
foreach (var item in document.RootElement.GetProperty("vectors").EnumerateArray())
|
||||||
|
{
|
||||||
|
result.Add(new Vector(
|
||||||
|
item.GetProperty("kid").GetString()!,
|
||||||
|
item.GetProperty("secret_hex").GetString()!,
|
||||||
|
item.GetProperty("timestamp").GetInt64(),
|
||||||
|
item.GetProperty("body").GetString()!,
|
||||||
|
item.GetProperty("expected_signature").GetString()!));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Vector(
|
||||||
|
string Kid,
|
||||||
|
string SecretHex,
|
||||||
|
long Timestamp,
|
||||||
|
string Body,
|
||||||
|
string ExpectedSignature);
|
||||||
|
|
||||||
|
private sealed class VectorSecretProvider : IWorkOrderWebhookSecretProvider
|
||||||
|
{
|
||||||
|
private readonly string _kid;
|
||||||
|
private readonly byte[] _secret;
|
||||||
|
|
||||||
|
public VectorSecretProvider(string kid, string secretHex)
|
||||||
|
{
|
||||||
|
_kid = kid;
|
||||||
|
_secret = Encoding.UTF8.GetBytes(secretHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkOrderWebhookSecretResult> GetSecretAsync(
|
||||||
|
string keyId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(string.Equals(keyId, _kid, StringComparison.Ordinal)
|
||||||
|
? new WorkOrderWebhookSecretResult(
|
||||||
|
WorkOrderWebhookSecretStatus.Found,
|
||||||
|
(byte[])_secret.Clone())
|
||||||
|
: new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingDataService : IWorkOrderWebhookDataService
|
||||||
|
{
|
||||||
|
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||||
|
WorkOrderWebhookMutation mutation,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new WorkOrderWebhookPersistenceResult(
|
||||||
|
WorkOrderWebhookPersistenceStatus.Applied));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs
Normal file
22
SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderMigrationDiscoveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Phase7_ExternalWorkOrderIdUnique_is_discoverable_by_ef_runtime()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
using var context = new ApplicationDbContext(options);
|
||||||
|
var migrations = context.Database.GetMigrations().ToList();
|
||||||
|
|
||||||
|
Assert.Contains("20260713120000_Phase7_ExternalWorkOrderIdUnique", migrations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,39 @@ public sealed class WorkOrderWebhookServiceTests
|
||||||
Assert.Equal(cts.Token, data.CancellationToken);
|
Assert.Equal(cts.Token, data.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authentic_wire_source_is_accepted_and_persisted_as_canonical()
|
||||||
|
{
|
||||||
|
var data = new RecordingDataService();
|
||||||
|
var service = CreateService(data);
|
||||||
|
var body = ValidBody();
|
||||||
|
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
|
var result = await service.ProcessAsync(
|
||||||
|
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status);
|
||||||
|
Assert.NotNull(data.Mutation);
|
||||||
|
Assert.Equal("procurement", data.Mutation!.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Legacy_procurement_wire_source_is_rejected_before_persistence()
|
||||||
|
{
|
||||||
|
var data = new RecordingDataService();
|
||||||
|
var service = CreateService(data);
|
||||||
|
var body = ValidBody(source: "procurement");
|
||||||
|
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
|
var result = await service.ProcessAsync(
|
||||||
|
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, result.Status);
|
||||||
|
Assert.Null(data.Mutation);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Provider_external_id_is_preserved_without_internal_number_normalization()
|
public async Task Provider_external_id_is_preserved_without_internal_number_normalization()
|
||||||
{
|
{
|
||||||
|
|
@ -132,7 +165,7 @@ public sealed class WorkOrderWebhookServiceTests
|
||||||
var service = CreateService(data);
|
var service = CreateService(data);
|
||||||
var body = Encoding.UTF8.GetBytes("""
|
var body = Encoding.UTF8.GetBytes("""
|
||||||
{"schema_version":"one","delivery_id":"d","event_type":"work_order.created",
|
{"schema_version":"one","delivery_id":"d","event_type":"work_order.created",
|
||||||
"occurred_at":"2026-07-24T12:00:00Z","source":"procurement","replay":false,
|
"occurred_at":"2026-07-24T12:00:00Z","source":"procurement-ingest/workorder-shoc-emitter","replay":false,
|
||||||
"data":{"work_order_id":"123"}}
|
"data":{"work_order_id":"123"}}
|
||||||
""");
|
""");
|
||||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||||
|
|
@ -153,18 +186,19 @@ public sealed class WorkOrderWebhookServiceTests
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
AllowedClockSkewSeconds = 300,
|
AllowedClockSkewSeconds = 300,
|
||||||
KeySecrets = new Dictionary<string, string> { ["current"] = "unused" }
|
SecretId = "workorder-ingest/shoc-webhook-hmac"
|
||||||
}),
|
}),
|
||||||
new FixedTimeProvider(Now),
|
new FixedTimeProvider(Now),
|
||||||
NullLogger<WorkOrderWebhookService>.Instance);
|
NullLogger<WorkOrderWebhookService>.Instance);
|
||||||
|
|
||||||
private static byte[] ValidBody(
|
private static byte[] ValidBody(
|
||||||
string deliveryId = "delivery-1",
|
string deliveryId = "delivery-1",
|
||||||
string workOrderId = "WO-123") =>
|
string workOrderId = "WO-123",
|
||||||
|
string source = "procurement-ingest/workorder-shoc-emitter") =>
|
||||||
Encoding.UTF8.GetBytes(
|
Encoding.UTF8.GetBytes(
|
||||||
"{\"schema_version\":1,\"delivery_id\":\"" + deliveryId
|
"{\"schema_version\":1,\"delivery_id\":\"" + deliveryId
|
||||||
+ "\",\"event_type\":\"work_order.created\","
|
+ "\",\"event_type\":\"work_order.created\","
|
||||||
+ "\"occurred_at\":\"2026-07-24T11:59:00Z\",\"source\":\"procurement\","
|
+ "\"occurred_at\":\"2026-07-24T11:59:00Z\",\"source\":\"" + source + "\","
|
||||||
+ "\"replay\":false,\"data\":{\"work_order_id\":\"" + workOrderId + "\","
|
+ "\"replay\":false,\"data\":{\"work_order_id\":\"" + workOrderId + "\","
|
||||||
+ "\"title\":\"Leaking pipe\",\"wo_status\":\"new\",\"severity\":\"2\"}}");
|
+ "\"title\":\"Leaking pipe\",\"wo_status\":\"new\",\"severity\":\"2\"}}");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue