diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs index 45601e3..739c5e8 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs @@ -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(MockBehavior.Strict); client.Setup(c => c.GetSecretValueAsync( - It.Is(r => r.SecretId == "arn:configured"), + It.Is(r => r.SecretId == SecretId), It.IsAny())) - .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(), @@ -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(MockBehavior.Strict); - var provider = CreateProvider(client.Object); + client.Setup(c => c.GetSecretValueAsync( + It.Is(r => r.SecretId == SecretId), + It.IsAny())) + .ReturnsAsync(SecretResponse(CurrentKeyset)); + var provider = CreateProvider(client.Object, SecretId); + + // Prime the cache with a known key first so the second lookup exercises + // the refresh-on-unknown path. + await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None); var result = await provider.GetSecretAsync( "attacker-controlled-key", CancellationToken.None); Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status); - client.VerifyNoOtherCalls(); + // One prime + exactly one refresh on the unknown kid. + client.Verify( + c => c.GetSecretValueAsync( + It.IsAny(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task Rotation_is_discovered_after_cache_expiry() + { + var call = 0; + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.GetSecretValueAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => ++call == 1 ? SecretResponse(CurrentKeyset) : SecretResponse(RotatedKeyset)); + var provider = CreateProvider(client.Object, SecretId, cacheSeconds: 1); + + var before = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None); + Assert.Equal(Hex(64, 'a'), EncodingText(before.Secret!)); + + await Task.Delay(TimeSpan.FromMilliseconds(1100)); + var after = await provider.GetSecretAsync("2026-08-20T00", CancellationToken.None); + + Assert.Equal(WorkOrderWebhookSecretStatus.Found, after.Status); + Assert.Equal(Hex(64, 'b'), EncodingText(after.Secret!)); + } + + [Fact] + public async Task Malformed_keyset_returns_unavailable() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.GetSecretValueAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(SecretResponse("not-json")); + var provider = CreateProvider(client.Object, SecretId); + + var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None); + + Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status); + Assert.Null(result.Secret); + } + + [Fact] + public async Task Duplicate_kid_returns_unavailable() + { + var duplicate = KeysetDocument( + ("2026-07-20T00", Hex(64, 'a')), + ("2026-07-20T00", Hex(64, 'b'))); + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.GetSecretValueAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(SecretResponse(duplicate)); + var provider = CreateProvider(client.Object, SecretId); + + var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None); + + Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status); } [Fact] @@ -58,29 +137,68 @@ public sealed class WorkOrderWebhookSecretProviderTests It.IsAny(), It.IsAny())) .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(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 - { - ["current"] = "arn:configured" - } + SecretCacheSeconds = cacheSeconds, + SecretId = secretId }), TimeProvider.System, NullLogger.Instance); + private static GetSecretValueResponse SecretResponse(string secretString) => + new() { SecretString = secretString }; + + private static string KeysetDocument(params (string Kid, string Secret)[] keys) + { + using var buffer = new MemoryStream(); + using var writer = new Utf8JsonWriter(buffer); + writer.WriteStartObject(); + writer.WritePropertyName("keys"); + writer.WriteStartArray(); + foreach (var (kid, secret) in keys) + { + writer.WriteStartObject(); + writer.WriteString("kid", kid); + writer.WriteString("secret", secret); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + } + + private static string Hex(int length, char digit) => new(digit, length); + + private static string EncodingText(byte[] secret) => + System.Text.Encoding.UTF8.GetString(secret); + private sealed class TestOptionsMonitor : IOptionsMonitor { public TestOptionsMonitor(WorkOrderWebhookOptions currentValue) diff --git a/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs b/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs index dd4f3f3..5ce526d 100644 --- a/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs +++ b/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs @@ -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 _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; - private readonly ConcurrentDictionary _cache = - new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _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(); + 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 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? 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(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 byKid) + { + ZeroSecrets(byKid); + return null; + } + + private static void ZeroSecrets(Dictionary 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 _byKid; + private DateTimeOffset _expiresAt; + + public DateTimeOffset ExpiresAt => _expiresAt; + + public CachedKeyset(Dictionary 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); + } + } } } diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index eaf063c..2af03ea 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -45,7 +45,7 @@ "AllowedClockSkewSeconds": 300, "SecretCacheSeconds": 300, "Region": "", - "KeySecrets": {} + "SecretId": "workorder-ingest/shoc-webhook-hmac" }, "WorkOrderReconciliation": { "Enabled": false, diff --git a/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs b/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs index 291a952..4119b67 100644 --- a/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs +++ b/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs @@ -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 { /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")] public partial class Phase7_ExternalWorkOrderIdUnique : Migration { /// diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs index 218769d..adc40c9 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs @@ -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( - "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 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 UpsertReceiptAsync( string source, string kind, diff --git a/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs b/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs index 0a6d291..cfb7f66 100644 --- a/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs +++ b/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs @@ -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 KeySecrets { get; set; } = - new(StringComparer.Ordinal); + public string? SecretId { get; set; } } } diff --git a/SeaHaven.Services/Constants/WorkOrderSourceIdentity.cs b/SeaHaven.Services/Constants/WorkOrderSourceIdentity.cs new file mode 100644 index 0000000..2f0ef95 --- /dev/null +++ b/SeaHaven.Services/Constants/WorkOrderSourceIdentity.cs @@ -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"; + } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index 19467fe..f44e262 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -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() .Bind(configuration.GetSection(WorkOrderReconciliationOptions.SectionName)) diff --git a/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs b/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs index 845a684..b19ecc3 100644 --- a/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs @@ -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, diff --git a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs index f5a3831..7756afc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs @@ -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, diff --git a/SeaHavenIndustries.Tests/Fixtures/shoc-webhook-test-vectors.json b/SeaHavenIndustries.Tests/Fixtures/shoc-webhook-test-vectors.json new file mode 100644 index 0000000..4d223f5 --- /dev/null +++ b/SeaHavenIndustries.Tests/Fixtures/shoc-webhook-test-vectors.json @@ -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=\" alongside X-SH-Timestamp: and X-SH-Key-Id: . 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" + } + ] +} diff --git a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj index e5dff3a..a689a94 100644 --- a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj +++ b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj @@ -28,4 +28,10 @@ + + + PreserveNewest + + + diff --git a/SeaHavenIndustries.Tests/ShocWebhookVectorTests.cs b/SeaHavenIndustries.Tests/ShocWebhookVectorTests.cs new file mode 100644 index 0000000..60d1f8e --- /dev/null +++ b/SeaHavenIndustries.Tests/ShocWebhookVectorTests.cs @@ -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(new WorkOrderWebhookOptions + { + Enabled = true, + AllowedClockSkewSeconds = 300, + SecretId = "workorder-ingest/shoc-webhook-hmac" + }), + new FixedTimeProvider(at), + NullLogger.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 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(new WorkOrderWebhookOptions + { + Enabled = true, + AllowedClockSkewSeconds = 300, + SecretId = "workorder-ingest/shoc-webhook-hmac" + }), + new FixedTimeProvider(at), + NullLogger.Instance); + + return await service.ProcessAsync( + new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body), + CancellationToken.None); + } + + private static async Task> LoadVectorsAsync() + { + await using var stream = File.OpenRead(FixturePath); + var document = await JsonDocument.ParseAsync(stream); + var result = new List(); + 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 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 ApplyAsync( + WorkOrderWebhookMutation mutation, + CancellationToken cancellationToken) => + Task.FromResult(new WorkOrderWebhookPersistenceResult( + WorkOrderWebhookPersistenceStatus.Applied)); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs new file mode 100644 index 0000000..5484926 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs @@ -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() + .UseSqlite("DataSource=:memory:") + .Options; + + using var context = new ApplicationDbContext(options); + var migrations = context.Database.GetMigrations().ToList(); + + Assert.Contains("20260713120000_Phase7_ExternalWorkOrderIdUnique", migrations); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs b/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs index 44513c2..f015ca3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs @@ -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 { ["current"] = "unused" } + SecretId = "workorder-ingest/shoc-webhook-hmac" }), new FixedTimeProvider(Now), NullLogger.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\"}}");