using System.Security.Cryptography; using System.Text; using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; public sealed class WorkOrderWebhookServiceTests { private static readonly byte[] Secret = Encoding.UTF8.GetBytes("test-secret-with-enough-entropy"); private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero); [Fact] public async Task Valid_signature_is_parsed_and_forwarded_with_cancellation() { var data = new RecordingDataService(); var service = CreateService(data); var body = ValidBody(); var timestamp = Now.ToUnixTimeSeconds().ToString(); using var cts = new CancellationTokenSource(); var result = await service.ProcessAsync( new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body), cts.Token); Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status); Assert.NotNull(data.Mutation); Assert.Equal("delivery-1", data.Mutation.DeliveryId); Assert.Equal("WO-123", data.Mutation.WorkerOrderNumber); 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() { var data = new RecordingDataService(); var service = CreateService(data); var externalId = "WO-PROCUREMENT-2026-000000000123"; var body = ValidBody(workOrderId: externalId); 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.Equal(externalId, data.Mutation!.ExternalWorkOrderId); Assert.Equal(externalId, data.Mutation.WorkerOrderNumber); } [Theory] [InlineData(-300, WorkOrderWebhookStatus.Applied)] [InlineData(300, WorkOrderWebhookStatus.Applied)] [InlineData(-301, WorkOrderWebhookStatus.Unauthorized)] [InlineData(301, WorkOrderWebhookStatus.Unauthorized)] public async Task Timestamp_boundaries_are_enforced( int offsetSeconds, WorkOrderWebhookStatus expected) { var data = new RecordingDataService(); var service = CreateService(data); var body = ValidBody(); var timestamp = Now.AddSeconds(offsetSeconds).ToUnixTimeSeconds().ToString(); var result = await service.ProcessAsync( new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body), CancellationToken.None); Assert.Equal(expected, result.Status); Assert.Equal(expected == WorkOrderWebhookStatus.Applied, data.Mutation != null); } [Fact] public async Task Tampered_body_is_rejected_before_parse_or_persistence() { var data = new RecordingDataService(); var service = CreateService(data); var signedBody = ValidBody(); var tampered = Encoding.UTF8.GetBytes("{not-json"); var timestamp = Now.ToUnixTimeSeconds().ToString(); var result = await service.ProcessAsync( new WorkOrderWebhookRequest( timestamp, "current", Sign(timestamp, signedBody), tampered), CancellationToken.None); Assert.Equal(WorkOrderWebhookStatus.Unauthorized, result.Status); Assert.Null(data.Mutation); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("v1=xyz")] public async Task Missing_or_malformed_signature_is_uniformly_unauthorized(string? signature) { var data = new RecordingDataService(); var service = CreateService(data); var body = ValidBody(); var result = await service.ProcessAsync( new WorkOrderWebhookRequest( Now.ToUnixTimeSeconds().ToString(), "current", signature, body), CancellationToken.None); Assert.Equal(WorkOrderWebhookStatus.Unauthorized, result.Status); Assert.Null(data.Mutation); } [Fact] public async Task Signed_malformed_known_type_is_invalid_without_persistence() { var data = new RecordingDataService(); 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-ingest/workorder-shoc-emitter","replay":false, "data":{"work_order_id":"123"}} """); 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); } private static WorkOrderWebhookService CreateService(RecordingDataService data) => new( new StaticSecretProvider(), data, new StaticOptionsMonitor(new WorkOrderWebhookOptions { Enabled = true, AllowedClockSkewSeconds = 300, SecretId = "workorder-ingest/shoc-webhook-hmac" }), new FixedTimeProvider(Now), NullLogger.Instance); private static byte[] ValidBody( string deliveryId = "delivery-1", 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\":\"" + source + "\"," + "\"replay\":false,\"data\":{\"work_order_id\":\"" + workOrderId + "\"," + "\"title\":\"Leaking pipe\",\"wo_status\":\"new\",\"severity\":\"2\"}}"); private static string Sign(string timestamp, byte[] body) { var prefix = Encoding.UTF8.GetBytes(timestamp + "."); var signed = new byte[prefix.Length + body.Length]; prefix.CopyTo(signed, 0); body.CopyTo(signed, prefix.Length); return "v1=" + Convert.ToHexString(HMACSHA256.HashData(Secret, signed)).ToLowerInvariant(); } private sealed class StaticSecretProvider : IWorkOrderWebhookSecretProvider { public Task GetSecretAsync( string keyId, CancellationToken cancellationToken) => Task.FromResult(keyId == "current" ? new WorkOrderWebhookSecretResult( WorkOrderWebhookSecretStatus.Found, (byte[])Secret.Clone()) : new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey)); } private sealed class RecordingDataService : IWorkOrderWebhookDataService { public WorkOrderWebhookMutation? Mutation { get; private set; } public CancellationToken CancellationToken { get; private set; } public Task ApplyAsync( WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) { Mutation = mutation; CancellationToken = cancellationToken; return Task.FromResult(new WorkOrderWebhookPersistenceResult( WorkOrderWebhookPersistenceStatus.Applied)); } } } public sealed class WorkOrderWebhookDataServiceTests { [Fact] public async Task State_comment_staleness_and_delivery_deduplication_are_atomic() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var context = new CountingDbContext(options); await context.Database.EnsureCreatedAsync(); context.Accounts.Add(new Accounts { Id = 1, Name = "Webhook Customer", IsDeleted = false }); await context.SaveChangesAsync(); context.ResetSaveCount(); var data = new WorkOrderWebhookDataService(context); var comment = Mutation( "comment-delivery", "hash-comment", "work_order.comment_added", new DateTimeOffset(2026, 7, 24, 10, 0, 0, TimeSpan.Zero), commentId: "comment-1"); var commentResult = await data.ApplyAsync(comment, CancellationToken.None); Assert.Equal(WorkOrderWebhookPersistenceStatus.Applied, commentResult.Status); var skeleton = await context.workOrders.SingleAsync(); Assert.Equal("123", skeleton.ExternalWorkOrderId); Assert.Equal("00000000001", skeleton.InternalWONumber); Assert.Equal("00000000123", skeleton.WorkerOrderNumber); Assert.Equal(skeleton.Id, (await context.Comments.SingleAsync()).WorkerOrderId); var state = Mutation( "state-delivery", "hash-state", "work_order.updated", new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero), title: "Current title"); await data.ApplyAsync(state, CancellationToken.None); var stale = Mutation( "stale-delivery", "hash-stale", "work_order.updated", new DateTimeOffset(2026, 7, 24, 11, 0, 0, TimeSpan.Zero), title: "Stale title"); var staleResult = await data.ApplyAsync(stale, CancellationToken.None); Assert.True(staleResult.StateMutationSkipped); Assert.Equal("Current title", (await context.workOrders.SingleAsync()).WorkerOrderTitle); Assert.Equal(3, await context.WorkOrderWebhookDeliveries.CountAsync()); var duplicate = await data.ApplyAsync(state, CancellationToken.None); Assert.Equal(WorkOrderWebhookPersistenceStatus.Duplicate, duplicate.Status); var conflict = await data.ApplyAsync( state with { BodySha256 = "different-hash" }, CancellationToken.None); Assert.Equal(WorkOrderWebhookPersistenceStatus.HashConflict, conflict.Status); Assert.Equal(3, context.SaveCount); } [Fact] public async Task Cancel_event_forces_cancelled_status() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var context = new ApplicationDbContext(options); await context.Database.EnsureCreatedAsync(); context.Accounts.Add(new Accounts { Id = 1, Name = "Webhook Customer", IsDeleted = false }); await context.SaveChangesAsync(); var data = new WorkOrderWebhookDataService(context); var mutation = Mutation( "cancel-delivery", "hash", "work_order.cancelled", new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with { IsCancelled = true, Status = "Open", LifecycleStatus = LifecycleStatus.Canceled }; await data.ApplyAsync(mutation, CancellationToken.None); Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status); Assert.Equal(LifecycleStatus.Canceled, (await context.workOrders.SingleAsync()).LifecycleStatus); } [Fact] public async Task Unmapped_imported_status_preserves_existing_lifecycle_status() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var context = new ApplicationDbContext(options); await context.Database.EnsureCreatedAsync(); context.workOrders.Add(new WorkOrder { ExternalWorkOrderId = "123", LifecycleStatus = LifecycleStatus.Scheduled }); await context.SaveChangesAsync(); var data = new WorkOrderWebhookDataService(context); await data.ApplyAsync( Mutation( "unmapped-status", "hash", "work_order.updated", new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)), CancellationToken.None); Assert.Equal(LifecycleStatus.Scheduled, (await context.workOrders.SingleAsync()).LifecycleStatus); } private static WorkOrderWebhookMutation Mutation( string deliveryId, string bodyHash, string eventType, DateTimeOffset occurredAt, string? title = null, string? commentId = null) => new() { DeliveryId = deliveryId, EventType = eventType, OccurredAt = occurredAt, UpdatedAt = occurredAt, ProcessedAt = occurredAt.AddMinutes(1), BodySha256 = bodyHash, VersionHash = bodyHash, ExternalWorkOrderId = "123", WorkerOrderNumber = "00000000123", Source = "procurement", IsStateEvent = eventType != "work_order.comment_added", Title = title, Customer = "Webhook Customer", Status = "Open", CommentId = commentId, CommentText = commentId == null ? null : "A comment" }; private sealed class CountingDbContext : ApplicationDbContext { public CountingDbContext(DbContextOptions options) : base(options) { } public int SaveCount { get; private set; } public void ResetSaveCount() => SaveCount = 0; public override Task SaveChangesAsync(CancellationToken cancellationToken = default) { SaveCount++; return base.SaveChangesAsync(cancellationToken); } } } public sealed class WorkOrderWebhookControllerTests { [Theory] [InlineData("text/plain")] [InlineData("application/xml")] public async Task Unsupported_media_type_is_rejected_before_service(string contentType) { var fake = new ControllerService(); var controller = CreateController(fake, Encoding.UTF8.GetBytes("{}"), contentType); var result = await controller.Receive(CancellationToken.None); Assert.Equal(StatusCodes.Status415UnsupportedMediaType, Assert.IsType(result).StatusCode); Assert.False(fake.Called); } [Fact] public async Task Streaming_body_over_limit_is_rejected_before_service() { var fake = new ControllerService { MaximumBodyBytes = 4 }; var controller = CreateController( fake, Encoding.UTF8.GetBytes("12345"), "application/json", contentLength: null); var result = await controller.Receive(CancellationToken.None); Assert.Equal(StatusCodes.Status413PayloadTooLarge, Assert.IsType(result).StatusCode); Assert.False(fake.Called); } [Fact] public async Task Valid_request_forwards_exact_body_and_cancellation() { var body = Encoding.UTF8.GetBytes("{\"x\":1}"); var fake = new ControllerService(); var controller = CreateController(fake, body, "application/json; charset=utf-8"); controller.Request.Headers["X-SH-Timestamp"] = "1"; controller.Request.Headers["X-SH-Key-Id"] = "key"; controller.Request.Headers["X-SH-Signature"] = "sig"; using var cts = new CancellationTokenSource(); var result = await controller.Receive(cts.Token); Assert.IsType(result); Assert.Equal(body, fake.Request!.Body); Assert.Equal(cts.Token, fake.CancellationToken); } private static WorkOrderWebhookController CreateController( ControllerService service, byte[] body, string contentType, long? contentLength = 0) { var context = new DefaultHttpContext(); context.Request.Body = new MemoryStream(body); context.Request.ContentType = contentType; context.Request.ContentLength = contentLength == 0 ? body.Length : contentLength; return new WorkOrderWebhookController(service) { ControllerContext = new ControllerContext { HttpContext = context } }; } private sealed class ControllerService : IWorkOrderWebhookService { public int MaximumBodyBytes { get; set; } = 1_048_576; public bool Called { get; private set; } public WorkOrderWebhookRequest? Request { get; private set; } public CancellationToken CancellationToken { get; private set; } public Task ProcessAsync( WorkOrderWebhookRequest request, CancellationToken cancellationToken) { Called = true; Request = request; CancellationToken = cancellationToken; return Task.FromResult(new WorkOrderWebhookResult(WorkOrderWebhookStatus.Applied)); } } } internal sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _utcNow; public FixedTimeProvider(DateTimeOffset utcNow) { _utcNow = utcNow; } public override DateTimeOffset GetUtcNow() => _utcNow; } internal sealed class StaticOptionsMonitor : IOptionsMonitor { public StaticOptionsMonitor(T value) { CurrentValue = value; } public T CurrentValue { get; } public T Get(string? name) => CurrentValue; public IDisposable? OnChange(Action listener) => null; }