fix(work-orders): satisfy producer contract review

This commit is contained in:
Alexandre Brandizzi 2026-07-27 16:33:06 -03:00
parent 27bf81b7f8
commit f701899a83
15 changed files with 639 additions and 109 deletions

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Api.SeaHavenIndustries.Infrastructure;
@ -12,23 +13,33 @@ 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 == "arn:configured"),
It.Is<GetSecretValueRequest>(r => r.SecretId == SecretId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetSecretValueResponse { SecretString = "shared-secret" });
var provider = CreateProvider(client.Object);
.ReturnsAsync(SecretResponse(CurrentKeyset));
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;
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("shared-secret", System.Text.Encoding.UTF8.GetString(second.Secret!));
client.VerifyAll();
Assert.Equal(Hex(64, 'a'), EncodingText(second.Secret!));
client.Verify(
c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
@ -37,17 +48,85 @@ public sealed class WorkOrderWebhookSecretProviderTests
}
[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 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(
"attacker-controlled-key",
CancellationToken.None);
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]
@ -58,29 +137,68 @@ public sealed class WorkOrderWebhookSecretProviderTests
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.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.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) =>
IAmazonSecretsManager client,
string secretId,
int cacheSeconds = 300) =>
new(
client,
new TestOptionsMonitor(new WorkOrderWebhookOptions
{
SecretCacheSeconds = 300,
KeySecrets = new Dictionary<string, string>
{
["current"] = "arn:configured"
}
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)

View file

@ -1,6 +1,6 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Microsoft.Extensions.Logging;
@ -12,14 +12,17 @@ namespace Api.SeaHavenIndustries.Infrastructure
{
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 IOptionsMonitor<WorkOrderWebhookOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AwsWorkOrderWebhookSecretProvider> _logger;
private readonly ConcurrentDictionary<string, CacheEntry> _cache =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks =
new(StringComparer.Ordinal);
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private CachedKeyset? _cache;
public AwsWorkOrderWebhookSecretProvider(
IAmazonSecretsManager client,
@ -37,81 +40,208 @@ namespace Api.SeaHavenIndustries.Infrastructure
string keyId,
CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!options.KeySecrets.TryGetValue(keyId, out var secretArn)
|| string.IsNullOrWhiteSpace(secretArn))
{
if (_cache.TryRemove(keyId, out var removed))
CryptographicOperations.ZeroMemory(removed.Secret);
if (string.IsNullOrWhiteSpace(keyId) || keyId.Length > MaxKidLength)
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
}
var observed = _cache;
var now = _timeProvider.GetUtcNow();
if (_cache.TryGetValue(keyId, out var cached)
&& cached.SecretArn == secretArn
&& cached.ExpiresAt > now)
return FoundCopy(cached.Secret);
var keyLock = _locks.GetOrAdd(keyId, _ => new SemaphoreSlim(1, 1));
await keyLock.WaitAsync(cancellationToken);
try
if (observed != null && observed.ExpiresAt > now)
{
now = _timeProvider.GetUtcNow();
if (_cache.TryGetValue(keyId, out cached)
&& cached.SecretArn == secretArn
&& 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);
var match = observed.Lookup(keyId);
if (match != null)
return FoundCopy(match);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
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.");
var refresh = await RefreshAsync(observed, cancellationToken);
if (refresh.Status == RefreshStatus.Unavailable)
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
}
finally
{
keyLock.Release();
}
}
private static byte[] ReadSecret(GetSecretValueResponse response)
{
if (!string.IsNullOrEmpty(response.SecretString))
return Encoding.UTF8.GetBytes(response.SecretString);
var keyset = refresh.Keyset;
var lookup = keyset?.Lookup(keyId);
if (lookup != null)
return FoundCopy(lookup);
return response.SecretBinary?.ToArray() ?? Array.Empty<byte>();
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
}
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
private sealed record CacheEntry(
string SecretArn,
byte[] Secret,
DateTimeOffset ExpiresAt);
private async Task<RefreshResult> RefreshAsync(
CachedKeyset? observed,
CancellationToken cancellationToken)
{
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);
}
}
}
}

View file

@ -45,7 +45,7 @@
"AllowedClockSkewSeconds": 300,
"SecretCacheSeconds": 300,
"Region": "",
"KeySecrets": {}
"SecretId": "workorder-ingest/shoc-webhook-hmac"
},
"WorkOrderReconciliation": {
"Enabled": false,

View file

@ -1,3 +1,5 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")]
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -197,10 +198,7 @@ namespace SeaHaven.DataServices.Implementation
{
if (_context.Database.IsSqlServer())
{
return await _context.Database
.SqlQueryRaw<long>(
"SELECT NEXT VALUE FOR dbo.WorkOrderInternalNumberSequence AS [Value]")
.SingleAsync(cancellationToken);
return await AllocateSqlServerInternalNumberAsync(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(
string source,
string kind,

View file

@ -9,7 +9,6 @@ namespace SeaHaven.Services.Configuration
public int AllowedClockSkewSeconds { get; set; } = 300;
public int SecretCacheSeconds { get; set; } = 300;
public string? Region { get; set; }
public Dictionary<string, string> KeySecrets { get; set; } =
new(StringComparer.Ordinal);
public string? SecretId { get; set; }
}
}

View 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";
}
}

View file

@ -24,15 +24,11 @@ namespace SeaHaven.Services.DependencyInjection
.Validate(
o => o.MaxBodyBytes is > 0 and <= 1_048_576
&& 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.")
.Validate(
o => !o.Enabled || (o.KeySecrets.Count > 0
&& o.KeySecrets.All(kvp =>
!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.")
o => !o.Enabled || !string.IsNullOrWhiteSpace(o.SecretId),
"WorkOrderWebhook requires a non-empty SecretId when enabled.")
.ValidateOnStart();
services.AddOptions<WorkOrderReconciliationOptions>()
.Bind(configuration.GetSection(WorkOrderReconciliationOptions.SectionName))

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
@ -13,7 +14,6 @@ namespace SeaHaven.Services.Implementation
{
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService, IWorkOrderReconciliationRunner
{
private const string Source = "procurement";
private readonly IProcurementWorkOrderClient _client;
private readonly IWorkOrderWebhookDataService _workOrders;
private readonly IWorkOrderReconciliationDataService _jobs;
@ -260,7 +260,7 @@ namespace SeaHaven.Services.Implementation
VersionHash = hash,
ExternalWorkOrderId = item.WorkOrderId,
WorkerOrderNumber = item.WorkOrderId,
Source = Source,
Source = WorkOrderSourceIdentity.CanonicalSource,
IsStateEvent = true,
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
Description = item.Description,
@ -305,7 +305,7 @@ namespace SeaHaven.Services.Implementation
VersionHash = hash,
ExternalWorkOrderId = item.WorkOrderId,
WorkerOrderNumber = item.WorkOrderId,
Source = Source,
Source = WorkOrderSourceIdentity.CanonicalSource,
IsStateEvent = false,
CommentId = item.CommentId,
CommentText = item.Text,

View file

@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
@ -246,7 +247,7 @@ namespace SeaHaven.Services.Implementation
|| string.IsNullOrWhiteSpace(envelope.EventType)
|| envelope.EventType.Length > 64
|| envelope.OccurredAt == null
|| !string.Equals(envelope.Source, "procurement", StringComparison.Ordinal)
|| !string.Equals(envelope.Source, WorkOrderSourceIdentity.WireSource, StringComparison.Ordinal)
|| envelope.Replay == null
|| envelope.Data == null
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
@ -306,7 +307,7 @@ namespace SeaHaven.Services.Implementation
BodySha256 = bodyHash,
ExternalWorkOrderId = envelope.Data.WorkOrderId,
WorkerOrderNumber = envelope.Data.WorkOrderId,
Source = envelope.Source!,
Source = WorkOrderSourceIdentity.CanonicalSource,
UpdatedAt = updatedAt.Value,
VersionHash = versionHash,
IsStateEvent = !isComment,

View file

@ -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"
}
]
}

View file

@ -28,4 +28,10 @@
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\shoc-webhook-test-vectors.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View 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));
}
}

View 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);
}
}

View file

@ -42,6 +42,39 @@ public sealed class WorkOrderWebhookServiceTests
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]
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 body = Encoding.UTF8.GetBytes("""
{"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"}}
""");
var timestamp = Now.ToUnixTimeSeconds().ToString();
@ -153,18 +186,19 @@ public sealed class WorkOrderWebhookServiceTests
{
Enabled = true,
AllowedClockSkewSeconds = 300,
KeySecrets = new Dictionary<string, string> { ["current"] = "unused" }
SecretId = "workorder-ingest/shoc-webhook-hmac"
}),
new FixedTimeProvider(Now),
NullLogger<WorkOrderWebhookService>.Instance);
private static byte[] ValidBody(
string deliveryId = "delivery-1",
string workOrderId = "WO-123") =>
string workOrderId = "WO-123",
string source = "procurement-ingest/workorder-shoc-emitter") =>
Encoding.UTF8.GetBytes(
"{\"schema_version\":1,\"delivery_id\":\"" + deliveryId
+ "\",\"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 + "\","
+ "\"title\":\"Leaking pipe\",\"wo_status\":\"new\",\"severity\":\"2\"}}");