mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +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.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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
"AllowedClockSkewSeconds": 300,
|
||||
"SecretCacheSeconds": 300,
|
||||
"Region": "",
|
||||
"KeySecrets": {}
|
||||
"SecretId": "workorder-ingest/shoc-webhook-hmac"
|
||||
},
|
||||
"WorkOrderReconciliation": {
|
||||
"Enabled": false,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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(
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\shoc-webhook-test-vectors.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</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);
|
||||
}
|
||||
|
||||
[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\"}}");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue