shoc-backend/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs

518 lines
20 KiB
C#
Raw Normal View History

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<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
{
Enabled = true,
AllowedClockSkewSeconds = 300,
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 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<WorkOrderWebhookSecretResult> 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<WorkOrderWebhookPersistenceResult> 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<ApplicationDbContext>()
.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<ApplicationDbContext>()
.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<ApplicationDbContext>()
.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<ApplicationDbContext> options)
: base(options)
{
}
public int SaveCount { get; private set; }
public void ResetSaveCount() => SaveCount = 0;
public override Task<int> 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<StatusCodeResult>(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<StatusCodeResult>(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<OkObjectResult>(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<WorkOrderWebhookResult> 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<T> : IOptionsMonitor<T>
{
public StaticOptionsMonitor(T value)
{
CurrentValue = value;
}
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}