mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
Seed resolvable Customer accounts and mock account resolution so create-path CI tests match fail-closed account scope. Co-authored-by: Cursor <cursoragent@cursor.com>
517 lines
20 KiB
C#
517 lines
20 KiB
C#
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;
|
|
}
|