mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
feat: ingest signed procurement work-order webhooks
This commit is contained in:
parent
8f492c0faf
commit
bdffe77e42
19 changed files with 5318 additions and 114 deletions
|
|
@ -0,0 +1,98 @@
|
|||
using Amazon.SecretsManager;
|
||||
using Amazon.SecretsManager.Model;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class WorkOrderWebhookSecretProviderTests
|
||||
{
|
||||
[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.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetSecretValueResponse { SecretString = "shared-secret" });
|
||||
var provider = CreateProvider(client.Object);
|
||||
|
||||
var first = await provider.GetSecretAsync("current", CancellationToken.None);
|
||||
first.Secret![0] = 0;
|
||||
var second = await provider.GetSecretAsync("current", CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookSecretStatus.Found, second.Status);
|
||||
Assert.Equal("shared-secret", System.Text.Encoding.UTF8.GetString(second.Secret!));
|
||||
client.VerifyAll();
|
||||
client.Verify(
|
||||
c => c.GetSecretValueAsync(
|
||||
It.IsAny<GetSecretValueRequest>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_key_never_influences_an_aws_request()
|
||||
{
|
||||
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
|
||||
var provider = CreateProvider(client.Object);
|
||||
|
||||
var result = await provider.GetSecretAsync(
|
||||
"attacker-controlled-key",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status);
|
||||
client.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aws_failure_returns_unavailable_without_exposing_the_exception()
|
||||
{
|
||||
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
|
||||
client.Setup(c => c.GetSecretValueAsync(
|
||||
It.IsAny<GetSecretValueRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("sensitive provider detail"));
|
||||
var provider = CreateProvider(client.Object);
|
||||
|
||||
var result = await provider.GetSecretAsync("current", CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
|
||||
Assert.Null(result.Secret);
|
||||
}
|
||||
|
||||
private static AwsWorkOrderWebhookSecretProvider CreateProvider(
|
||||
IAmazonSecretsManager client) =>
|
||||
new(
|
||||
client,
|
||||
new TestOptionsMonitor(new WorkOrderWebhookOptions
|
||||
{
|
||||
SecretCacheSeconds = 300,
|
||||
KeySecrets = new Dictionary<string, string>
|
||||
{
|
||||
["current"] = "arn:configured"
|
||||
}
|
||||
}),
|
||||
TimeProvider.System,
|
||||
NullLogger<AwsWorkOrderWebhookSecretProvider>.Instance);
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<WorkOrderWebhookOptions>
|
||||
{
|
||||
public TestOptionsMonitor(WorkOrderWebhookOptions currentValue)
|
||||
{
|
||||
CurrentValue = currentValue;
|
||||
}
|
||||
|
||||
public WorkOrderWebhookOptions CurrentValue { get; }
|
||||
|
||||
public WorkOrderWebhookOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(
|
||||
Action<WorkOrderWebhookOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.DynamoDBv2" Version="4.0.17.9" />
|
||||
<PackageReference Include="AWSSDK.SecretsManager" Version="4.0.100.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
|
|
|||
114
Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs
Normal file
114
Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System.Buffers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/webhooks/work-orders")]
|
||||
public sealed class WorkOrderWebhookController : ControllerBase
|
||||
{
|
||||
private readonly IWorkOrderWebhookService _service;
|
||||
|
||||
public WorkOrderWebhookController(IWorkOrderWebhookService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> Receive(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!HasSupportedContentType(Request.ContentType))
|
||||
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
|
||||
|
||||
if (Request.Headers.ContentEncoding.Count > 0
|
||||
&& !string.Equals(
|
||||
Request.Headers.ContentEncoding.ToString(),
|
||||
"identity",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
|
||||
|
||||
var maxBytes = _service.MaximumBodyBytes;
|
||||
if (Request.ContentLength > maxBytes)
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
|
||||
var body = await ReadBoundedBodyAsync(Request.Body, maxBytes, cancellationToken);
|
||||
if (body == null)
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
|
||||
var result = await _service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(
|
||||
SingleHeader("X-SH-Timestamp"),
|
||||
SingleHeader("X-SH-Key-Id"),
|
||||
SingleHeader("X-SH-Signature"),
|
||||
body),
|
||||
cancellationToken);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
WorkOrderWebhookStatus.Applied => Ok(new
|
||||
{
|
||||
status = "accepted",
|
||||
state_mutation_skipped = result.StateMutationSkipped
|
||||
}),
|
||||
WorkOrderWebhookStatus.Duplicate => Ok(new { status = "duplicate" }),
|
||||
WorkOrderWebhookStatus.Unauthorized => Unauthorized(new { error = "unauthorized" }),
|
||||
WorkOrderWebhookStatus.Disabled => StatusCode(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
new { error = "webhook unavailable" }),
|
||||
WorkOrderWebhookStatus.InvalidEnvelope => BadRequest(new { error = "invalid envelope" }),
|
||||
WorkOrderWebhookStatus.HashConflict => Conflict(new { error = "delivery conflict" }),
|
||||
WorkOrderWebhookStatus.Unavailable => StatusCode(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
new { error = "webhook unavailable" }),
|
||||
_ => StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||
};
|
||||
}
|
||||
|
||||
private string? SingleHeader(string name)
|
||||
{
|
||||
var values = Request.Headers[name];
|
||||
return values.Count == 1 ? values[0] : null;
|
||||
}
|
||||
|
||||
private static bool HasSupportedContentType(string? contentType)
|
||||
{
|
||||
return MediaTypeHeaderValue.TryParse(contentType, out var parsed)
|
||||
&& string.Equals(
|
||||
parsed.MediaType.Value,
|
||||
"application/json",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> ReadBoundedBodyAsync(
|
||||
Stream source,
|
||||
int maxBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rented = ArrayPool<byte>.Shared.Rent(81920);
|
||||
try
|
||||
{
|
||||
using var destination = new MemoryStream(Math.Min(maxBytes, 81920));
|
||||
while (true)
|
||||
{
|
||||
var read = await source.ReadAsync(rented.AsMemory(0, rented.Length), cancellationToken);
|
||||
if (read == 0)
|
||||
return destination.ToArray();
|
||||
|
||||
if (destination.Length + read > maxBytes)
|
||||
return null;
|
||||
|
||||
await destination.WriteAsync(rented.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(rented, clearArray: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Amazon.SecretsManager;
|
||||
using Amazon.SecretsManager.Model;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Infrastructure
|
||||
{
|
||||
public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider
|
||||
{
|
||||
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);
|
||||
|
||||
public AwsWorkOrderWebhookSecretProvider(
|
||||
IAmazonSecretsManager client,
|
||||
IOptionsMonitor<WorkOrderWebhookOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AwsWorkOrderWebhookSecretProvider> logger)
|
||||
{
|
||||
_client = client;
|
||||
_options = options;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderWebhookSecretResult> GetSecretAsync(
|
||||
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);
|
||||
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
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.");
|
||||
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);
|
||||
|
||||
return response.SecretBinary?.ToArray() ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
|
||||
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
|
||||
|
||||
private sealed record CacheEntry(
|
||||
string SecretArn,
|
||||
byte[] Secret,
|
||||
DateTimeOffset ExpiresAt);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,9 +67,34 @@ builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.Se
|
|||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
|
||||
builder.Services.AddSingleton<Amazon.SecretsManager.IAmazonSecretsManager>(_ =>
|
||||
{
|
||||
var region = builder.Configuration[$"{SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName}:Region"];
|
||||
return string.IsNullOrWhiteSpace(region)
|
||||
? new Amazon.SecretsManager.AmazonSecretsManagerClient()
|
||||
: new Amazon.SecretsManager.AmazonSecretsManagerClient(Amazon.RegionEndpoint.GetBySystemName(region));
|
||||
});
|
||||
builder.Services.AddSingleton<
|
||||
SeaHaven.Services.Interfaces.IWorkOrderWebhookSecretProvider,
|
||||
Api.SeaHavenIndustries.Infrastructure.AwsWorkOrderWebhookSecretProvider>();
|
||||
|
||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
||||
builder.Services.AddOptions<SeaHaven.Services.Configuration.WorkOrderWebhookOptions>()
|
||||
.Bind(builder.Configuration.GetSection(SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName))
|
||||
.Validate(
|
||||
o => o.MaxBodyBytes is > 0 and <= 1_048_576
|
||||
&& o.AllowedClockSkewSeconds is >= 0 and <= 3600
|
||||
&& o.SecretCacheSeconds is > 0 and <= 3600,
|
||||
"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.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
||||
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
||||
.Validate(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@
|
|||
"Enabled": false,
|
||||
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
|
||||
},
|
||||
"WorkOrderWebhook": {
|
||||
"Enabled": false,
|
||||
"MaxBodyBytes": 1048576,
|
||||
"AllowedClockSkewSeconds": 300,
|
||||
"SecretCacheSeconds": 300,
|
||||
"Region": "",
|
||||
"KeySecrets": {}
|
||||
},
|
||||
"Sync": {
|
||||
"Enabled": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -73,6 +73,17 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
// These indexes already exist in the migration history. Keep them in
|
||||
// the runtime model so future additive migrations do not drop them.
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => new { w.AssignTo, w.ScheduledDate });
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.ScheduledDate);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.WorkOrderType);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.ExternalWorkOrderId)
|
||||
.HasMaxLength(450);
|
||||
|
|
@ -82,6 +93,10 @@ namespace Data.SeaHavenIndustries
|
|||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
||||
builder.Entity<WorkOrderWebhookDelivery>()
|
||||
.HasIndex(d => d.DeliveryId)
|
||||
.IsUnique();
|
||||
|
||||
|
||||
builder.Entity<Dispatch>()
|
||||
.Property(d => d.RowVersion)
|
||||
|
|
@ -129,6 +144,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
|
||||
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
||||
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
||||
public DbSet<WorkOrderWebhookDelivery> WorkOrderWebhookDeliveries { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||
|
|
|
|||
3638
Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.Designer.cs
generated
Normal file
3638
Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,67 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH133_WorkOrderWebhookIngestion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ExternalLastOccurredAt",
|
||||
table: "workOrders",
|
||||
type: "datetimeoffset",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkOrderWebhookDeliveries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
DeliveryId = table.Column<string>(
|
||||
type: "nvarchar(128)",
|
||||
maxLength: 128,
|
||||
nullable: false),
|
||||
EventType = table.Column<string>(
|
||||
type: "nvarchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: false),
|
||||
OccurredAt = table.Column<DateTimeOffset>(
|
||||
type: "datetimeoffset",
|
||||
nullable: false),
|
||||
ProcessedAt = table.Column<DateTimeOffset>(
|
||||
type: "datetimeoffset",
|
||||
nullable: false),
|
||||
BodySha256 = table.Column<string>(
|
||||
type: "nvarchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkOrderWebhookDeliveries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkOrderWebhookDeliveries_DeliveryId",
|
||||
table: "WorkOrderWebhookDeliveries",
|
||||
column: "DeliveryId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkOrderWebhookDeliveries");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalLastOccurredAt",
|
||||
table: "workOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts", (string)null);
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
|
||||
|
|
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("Addresses", (string)null);
|
||||
b.ToTable("Addresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
|
|
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Assets", (string)null);
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
|
||||
|
|
@ -371,62 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ServiceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("TemplateUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceKey", "IsActive")
|
||||
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
|
||||
|
||||
b.ToTable("CompletionDocTemplates", (string)null);
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
|
||||
|
|
@ -493,7 +438,61 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkerOrderId");
|
||||
|
||||
b.ToTable("Comments", (string)null);
|
||||
b.ToTable("Comments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ServiceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("TemplateUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceKey", "IsActive");
|
||||
|
||||
b.ToTable("CompletionDocTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
|
||||
|
|
@ -559,7 +558,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("ContactDetails", (string)null);
|
||||
b.ToTable("ContactDetails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
|
||||
|
|
@ -651,7 +650,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Contacts", (string)null);
|
||||
b.ToTable("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
|
||||
|
|
@ -669,7 +668,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Departments", (string)null);
|
||||
b.ToTable("Departments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
|
||||
|
|
@ -788,7 +787,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("Dispatches", (string)null);
|
||||
b.ToTable("Dispatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
|
||||
|
|
@ -847,7 +846,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchChecklistItems", (string)null);
|
||||
b.ToTable("DispatchChecklistItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
|
||||
|
|
@ -901,7 +900,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.ToTable("DispatchSignoffs", (string)null);
|
||||
b.ToTable("DispatchSignoffs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
|
||||
|
|
@ -971,7 +970,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests", (string)null);
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
@ -994,7 +993,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchWorkOrders", (string)null);
|
||||
b.ToTable("DispatchWorkOrders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
|
||||
|
|
@ -1024,7 +1023,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DropdownOptions", (string)null);
|
||||
b.ToTable("DropdownOptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
|
||||
|
|
@ -1129,7 +1128,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("RegionId");
|
||||
|
||||
b.ToTable("Employees", (string)null);
|
||||
b.ToTable("Employees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
|
||||
|
|
@ -1169,7 +1168,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("EmployeeId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeAddresses", (string)null);
|
||||
b.ToTable("EmployeeAddresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
|
||||
|
|
@ -1197,7 +1196,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeeEmails", (string)null);
|
||||
b.ToTable("EmployeeEmails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
|
||||
|
|
@ -1227,7 +1226,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeePhones", (string)null);
|
||||
b.ToTable("EmployeePhones");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
|
|
@ -1375,7 +1374,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("FollowUps", (string)null);
|
||||
b.ToTable("FollowUps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
|
||||
|
|
@ -1400,7 +1399,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ForgetPasswordCodes", (string)null);
|
||||
b.ToTable("ForgetPasswordCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
|
||||
|
|
@ -1418,7 +1417,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobTitles", (string)null);
|
||||
b.ToTable("JobTitles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||
|
|
@ -1488,7 +1487,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Locations", (string)null);
|
||||
b.ToTable("Locations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
|
||||
|
|
@ -1546,7 +1545,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("PMSchedules", (string)null);
|
||||
b.ToTable("PMSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
|
||||
|
|
@ -1620,7 +1619,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("Quotes", (string)null);
|
||||
b.ToTable("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
|
||||
|
|
@ -1668,7 +1667,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("QuoteId");
|
||||
|
||||
b.ToTable("QuotesLines", (string)null);
|
||||
b.ToTable("QuotesLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
|
||||
|
|
@ -1686,7 +1685,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Regions", (string)null);
|
||||
b.ToTable("Regions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
|
|
@ -1788,7 +1787,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaskListTemplates", (string)null);
|
||||
b.ToTable("TaskListTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
|
|
@ -1833,7 +1832,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("TaskListTemplateId");
|
||||
|
||||
b.ToTable("TaskListTemplateItems", (string)null);
|
||||
b.ToTable("TaskListTemplateItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||
|
|
@ -1935,7 +1934,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Templates", (string)null);
|
||||
b.ToTable("Templates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
|
|
@ -2023,7 +2022,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("Vendors", (string)null);
|
||||
b.ToTable("Vendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
|
|
@ -2082,7 +2081,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
b.ToTable("VendorAccessTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
|
||||
|
|
@ -2339,6 +2338,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateTime?>("DueDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExternalLastOccurredAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("ExternalWorkOrderId")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
|
@ -2499,10 +2501,6 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssignTo");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("ExternalWorkOrderId")
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
|
@ -2510,9 +2508,6 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("InternalWONumber")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
|
@ -2524,7 +2519,12 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.ToTable("workOrders", (string)null);
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -2569,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderAttachments", (string)null);
|
||||
b.ToTable("workOrderAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
|
||||
|
|
@ -2619,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("WorkOrderAuditLogs", (string)null);
|
||||
b.ToTable("WorkOrderAuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
|
||||
|
|
@ -2663,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderCategories", (string)null);
|
||||
b.ToTable("workOrderCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
|
||||
|
|
@ -2710,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("WorkOrderContacts", (string)null);
|
||||
b.ToTable("WorkOrderContacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
|
|
@ -2739,10 +2739,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "FieldName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderFieldLocks", (string)null);
|
||||
b.ToTable("WorkOrderFieldLocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
|
|
@ -2750,25 +2750,33 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
b.Property<string>("BodySha256")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
b.Property<string>("DeliveryId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
b.Property<DateTimeOffset>("OccurredAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("ProcessedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
b.HasIndex("DeliveryId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
b.ToTable("WorkOrderWebhookDeliveries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
|
|
@ -3433,10 +3441,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
.WithMany()
|
||||
.WithMany("FieldLocks")
|
||||
.HasForeignKey("WorkOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
|
@ -3444,10 +3452,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
.WithMany("FieldLocks")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
|
|
|||
21
Data.SeaHavenIndustries/Models/WorkOrderWebhookDelivery.cs
Normal file
21
Data.SeaHavenIndustries/Models/WorkOrderWebhookDelivery.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public sealed class WorkOrderWebhookDelivery
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string DeliveryId { get; set; } = "";
|
||||
|
||||
[MaxLength(64)]
|
||||
public string EventType { get; set; } = "";
|
||||
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
public DateTimeOffset ProcessedAt { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string BodySha256 { get; set; } = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ namespace Data.SeaHavenIndustries
|
|||
// --- Core slice ---
|
||||
public string? InternalWONumber { get; set; }
|
||||
public string? ExternalWorkOrderId { get; set; }
|
||||
public DateTimeOffset? ExternalLastOccurredAt { get; set; }
|
||||
public string? WorkerOrderNumber { get; set; }
|
||||
public string? WorkerOrderTitle { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public sealed class WorkOrderWebhookDataService : IWorkOrderWebhookDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderWebhookDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||
WorkOrderWebhookMutation mutation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var prior = await _context.WorkOrderWebhookDeliveries
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(d => d.DeliveryId == mutation.DeliveryId, cancellationToken);
|
||||
|
||||
if (prior != null)
|
||||
return ExistingDeliveryResult(prior, mutation.BodySha256);
|
||||
|
||||
var workOrder = await _context.workOrders
|
||||
.SingleOrDefaultAsync(
|
||||
w => w.ExternalWorkOrderId == mutation.ExternalWorkOrderId,
|
||||
cancellationToken);
|
||||
|
||||
if (workOrder == null)
|
||||
{
|
||||
var nextInternalNumber = Math.Max(
|
||||
await _context.workOrders.MaxAsync(
|
||||
w => (int?)w.Id,
|
||||
cancellationToken) ?? 0,
|
||||
0)
|
||||
+ 1;
|
||||
workOrder = new WorkOrder
|
||||
{
|
||||
ExternalWorkOrderId = mutation.ExternalWorkOrderId,
|
||||
WorkerOrderNumber = mutation.WorkerOrderNumber,
|
||||
InternalWONumber = nextInternalNumber.ToString("D11"),
|
||||
WorkerOrderTitle = mutation.Title ?? mutation.Description
|
||||
?? $"Imported work order {mutation.ExternalWorkOrderId}",
|
||||
Description = mutation.Description,
|
||||
Source = mutation.Source,
|
||||
istemplate = false,
|
||||
CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime
|
||||
};
|
||||
_context.workOrders.Add(workOrder);
|
||||
}
|
||||
|
||||
var staleState = mutation.IsStateEvent
|
||||
&& workOrder.ExternalLastOccurredAt.HasValue
|
||||
&& mutation.OccurredAt < workOrder.ExternalLastOccurredAt.Value;
|
||||
|
||||
if (mutation.IsStateEvent && !staleState)
|
||||
{
|
||||
var locationKey = mutation.SiteCode ?? mutation.Building;
|
||||
if (!string.IsNullOrWhiteSpace(locationKey))
|
||||
{
|
||||
var location = await _context.Locations
|
||||
.FirstOrDefaultAsync(
|
||||
l => l.Name == locationKey || l.Title == locationKey,
|
||||
cancellationToken);
|
||||
if (location == null)
|
||||
{
|
||||
location = new Locations
|
||||
{
|
||||
Name = locationKey,
|
||||
Title = mutation.Building,
|
||||
Address1 = mutation.Address,
|
||||
Status = "Active"
|
||||
};
|
||||
_context.Locations.Add(location);
|
||||
}
|
||||
|
||||
workOrder.Locations = location;
|
||||
}
|
||||
|
||||
workOrder.WorkerOrderNumber = mutation.WorkerOrderNumber;
|
||||
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
||||
workOrder.Description = mutation.Description;
|
||||
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
||||
workOrder.Severity = mutation.Severity;
|
||||
workOrder.Priority = mutation.Priority;
|
||||
workOrder.Customer = mutation.Customer;
|
||||
workOrder.SiteCode = mutation.SiteCode;
|
||||
workOrder.Building = mutation.Building;
|
||||
workOrder.DueDate = mutation.DueDate;
|
||||
workOrder.DateReported = mutation.DateReported;
|
||||
workOrder.ScheduledStart = mutation.ScheduledStart;
|
||||
workOrder.Source = mutation.Source;
|
||||
workOrder.istemplate = false;
|
||||
workOrder.ExternalLastOccurredAt = mutation.OccurredAt;
|
||||
}
|
||||
|
||||
if (mutation.CommentId != null)
|
||||
{
|
||||
var commentExists = await _context.Comments
|
||||
.AnyAsync(c => c.ExternalCommentId == mutation.CommentId, cancellationToken);
|
||||
|
||||
if (!commentExists)
|
||||
{
|
||||
_context.Comments.Add(new Comments
|
||||
{
|
||||
ExternalCommentId = mutation.CommentId,
|
||||
Commenttext = mutation.CommentText,
|
||||
Commenter = mutation.Commenter,
|
||||
CommentType = mutation.CommentType,
|
||||
RecordType = "WorkOrder",
|
||||
WorkOrder = workOrder,
|
||||
CreatedDate = mutation.OccurredAt.UtcDateTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_context.WorkOrderWebhookDeliveries.Add(new WorkOrderWebhookDelivery
|
||||
{
|
||||
DeliveryId = mutation.DeliveryId,
|
||||
EventType = mutation.EventType,
|
||||
OccurredAt = mutation.OccurredAt,
|
||||
ProcessedAt = mutation.ProcessedAt,
|
||||
BodySha256 = mutation.BodySha256
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return new WorkOrderWebhookPersistenceResult(
|
||||
WorkOrderWebhookPersistenceStatus.Applied,
|
||||
staleState);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
_context.ChangeTracker.Clear();
|
||||
var concurrent = await _context.WorkOrderWebhookDeliveries
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(d => d.DeliveryId == mutation.DeliveryId, cancellationToken);
|
||||
|
||||
if (concurrent == null)
|
||||
throw;
|
||||
|
||||
return ExistingDeliveryResult(concurrent, mutation.BodySha256);
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkOrderWebhookPersistenceResult ExistingDeliveryResult(
|
||||
WorkOrderWebhookDelivery delivery,
|
||||
string bodySha256)
|
||||
{
|
||||
return new WorkOrderWebhookPersistenceResult(
|
||||
string.Equals(delivery.BodySha256, bodySha256, StringComparison.OrdinalIgnoreCase)
|
||||
? WorkOrderWebhookPersistenceStatus.Duplicate
|
||||
: WorkOrderWebhookPersistenceStatus.HashConflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderWebhookDataService
|
||||
{
|
||||
Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||
WorkOrderWebhookMutation mutation,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record WorkOrderWebhookMutation
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventType { get; init; }
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
public required DateTimeOffset ProcessedAt { get; init; }
|
||||
public required string BodySha256 { get; init; }
|
||||
public required string ExternalWorkOrderId { get; init; }
|
||||
public required string WorkerOrderNumber { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public bool IsStateEvent { get; init; }
|
||||
public bool IsCancelled { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Priority { get; init; }
|
||||
public string? Customer { get; init; }
|
||||
public string? SiteCode { get; init; }
|
||||
public string? Building { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public DateTime? DueDate { get; init; }
|
||||
public DateTime? DateReported { get; init; }
|
||||
public DateTime? ScheduledStart { get; init; }
|
||||
public DateTime? CreatedAt { get; init; }
|
||||
public string? CommentId { get; init; }
|
||||
public string? CommentText { get; init; }
|
||||
public string? Commenter { get; init; }
|
||||
public string? CommentType { get; init; }
|
||||
}
|
||||
|
||||
public enum WorkOrderWebhookPersistenceStatus
|
||||
{
|
||||
Applied,
|
||||
Duplicate,
|
||||
HashConflict
|
||||
}
|
||||
|
||||
public sealed record WorkOrderWebhookPersistenceResult(
|
||||
WorkOrderWebhookPersistenceStatus Status,
|
||||
bool StateMutationSkipped = false);
|
||||
}
|
||||
15
SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs
Normal file
15
SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace SeaHaven.Services.Configuration
|
||||
{
|
||||
public sealed class WorkOrderWebhookOptions
|
||||
{
|
||||
public const string SectionName = "WorkOrderWebhook";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public int MaxBodyBytes { get; set; } = 1_048_576;
|
||||
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);
|
||||
}
|
||||
}
|
||||
371
SeaHaven.Services/Implementation/WorkOrderWebhookService.cs
Normal file
371
SeaHaven.Services/Implementation/WorkOrderWebhookService.cs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public sealed class WorkOrderWebhookService : IWorkOrderWebhookService
|
||||
{
|
||||
private static readonly Meter Meter = new("SeaHaven.WorkOrderWebhook", "1.0");
|
||||
private static readonly Counter<long> Accepted = Meter.CreateCounter<long>("work_order_webhook.accepted");
|
||||
private static readonly Counter<long> Duplicates = Meter.CreateCounter<long>("work_order_webhook.duplicate");
|
||||
private static readonly Counter<long> Rejected = Meter.CreateCounter<long>("work_order_webhook.rejected");
|
||||
private static readonly Counter<long> Invalid = Meter.CreateCounter<long>("work_order_webhook.invalid");
|
||||
private static readonly Counter<long> Failed = Meter.CreateCounter<long>("work_order_webhook.failed");
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
private readonly IWorkOrderWebhookSecretProvider _secretProvider;
|
||||
private readonly IWorkOrderWebhookDataService _dataService;
|
||||
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<WorkOrderWebhookService> _logger;
|
||||
|
||||
public WorkOrderWebhookService(
|
||||
IWorkOrderWebhookSecretProvider secretProvider,
|
||||
IWorkOrderWebhookDataService dataService,
|
||||
IOptionsMonitor<WorkOrderWebhookOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<WorkOrderWebhookService> logger)
|
||||
{
|
||||
_secretProvider = secretProvider;
|
||||
_dataService = dataService;
|
||||
_options = options;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public int MaximumBodyBytes =>
|
||||
Math.Clamp(_options.CurrentValue.MaxBodyBytes, 1, 1_048_576);
|
||||
|
||||
public async Task<WorkOrderWebhookResult> ProcessAsync(
|
||||
WorkOrderWebhookRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
if (!options.Enabled)
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Disabled);
|
||||
|
||||
if (!TryReadTimestamp(request.Timestamp, options.AllowedClockSkewSeconds, out var timestamp)
|
||||
|| string.IsNullOrWhiteSpace(request.KeyId)
|
||||
|| request.KeyId.Length > 128
|
||||
|| string.IsNullOrWhiteSpace(request.Signature))
|
||||
{
|
||||
Rejected.Add(1);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
WorkOrderWebhookSecretResult secretResult;
|
||||
try
|
||||
{
|
||||
secretResult = await _secretProvider.GetSecretAsync(request.KeyId, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Failed.Add(1);
|
||||
_logger.LogError("Work-order webhook signing secret could not be loaded.");
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable);
|
||||
}
|
||||
|
||||
if (secretResult.Status == WorkOrderWebhookSecretStatus.Unavailable)
|
||||
{
|
||||
Failed.Add(1);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable);
|
||||
}
|
||||
|
||||
if (secretResult.Status != WorkOrderWebhookSecretStatus.Found
|
||||
|| secretResult.Secret is not { Length: > 0 })
|
||||
{
|
||||
Rejected.Add(1);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var signatureValid = false;
|
||||
try
|
||||
{
|
||||
signatureValid = VerifySignature(
|
||||
request.Timestamp!,
|
||||
request.Body,
|
||||
request.Signature!,
|
||||
secretResult.Secret);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(secretResult.Secret);
|
||||
}
|
||||
|
||||
if (!signatureValid)
|
||||
{
|
||||
Rejected.Add(1);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
WorkOrderWebhookEnvelope? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<WorkOrderWebhookEnvelope>(request.Body, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
Invalid.Add(1);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope);
|
||||
}
|
||||
|
||||
if (!TryCreateMutation(envelope, request.Body, out var mutation))
|
||||
{
|
||||
Invalid.Add(1);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var persisted = await _dataService.ApplyAsync(mutation!, cancellationToken);
|
||||
switch (persisted.Status)
|
||||
{
|
||||
case WorkOrderWebhookPersistenceStatus.Applied:
|
||||
Accepted.Add(1);
|
||||
_logger.LogInformation(
|
||||
"Work-order webhook delivery {DeliveryId} was accepted. State mutation skipped: {StateMutationSkipped}.",
|
||||
mutation!.DeliveryId,
|
||||
persisted.StateMutationSkipped);
|
||||
return new WorkOrderWebhookResult(
|
||||
WorkOrderWebhookStatus.Applied,
|
||||
persisted.StateMutationSkipped);
|
||||
case WorkOrderWebhookPersistenceStatus.Duplicate:
|
||||
Duplicates.Add(1);
|
||||
_logger.LogInformation(
|
||||
"Work-order webhook delivery {DeliveryId} was already processed.",
|
||||
mutation!.DeliveryId);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Duplicate);
|
||||
case WorkOrderWebhookPersistenceStatus.HashConflict:
|
||||
Rejected.Add(1);
|
||||
_logger.LogWarning(
|
||||
"Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.",
|
||||
mutation!.DeliveryId);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict);
|
||||
default:
|
||||
throw new InvalidOperationException("Unknown persistence result.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Failed.Add(1);
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Work-order webhook delivery {DeliveryId} could not be persisted.",
|
||||
mutation!.DeliveryId);
|
||||
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadTimestamp(
|
||||
string? value,
|
||||
int allowedClockSkewSeconds,
|
||||
out DateTimeOffset timestamp)
|
||||
{
|
||||
timestamp = default;
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| value.Any(c => c is < '0' or > '9')
|
||||
|| !long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
timestamp = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var skew = (_timeProvider.GetUtcNow() - timestamp).Duration();
|
||||
return skew <= TimeSpan.FromSeconds(Math.Clamp(allowedClockSkewSeconds, 0, 3600));
|
||||
}
|
||||
|
||||
private static bool VerifySignature(
|
||||
string timestamp,
|
||||
byte[] body,
|
||||
string signature,
|
||||
byte[] secret)
|
||||
{
|
||||
const string prefix = "v1=";
|
||||
if (!signature.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| signature.Length != prefix.Length + 64)
|
||||
return false;
|
||||
|
||||
byte[] supplied;
|
||||
try
|
||||
{
|
||||
supplied = Convert.FromHexString(signature[prefix.Length..]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var timestampBytes = Encoding.UTF8.GetBytes(timestamp);
|
||||
var signed = new byte[timestampBytes.Length + 1 + body.Length];
|
||||
timestampBytes.CopyTo(signed, 0);
|
||||
signed[timestampBytes.Length] = (byte)'.';
|
||||
body.CopyTo(signed, timestampBytes.Length + 1);
|
||||
var expected = HMACSHA256.HashData(secret, signed);
|
||||
return CryptographicOperations.FixedTimeEquals(expected, supplied);
|
||||
}
|
||||
|
||||
private bool TryCreateMutation(
|
||||
WorkOrderWebhookEnvelope? envelope,
|
||||
byte[] body,
|
||||
out WorkOrderWebhookMutation? mutation)
|
||||
{
|
||||
mutation = null;
|
||||
if (envelope == null
|
||||
|| envelope.SchemaVersion != 1
|
||||
|| string.IsNullOrWhiteSpace(envelope.DeliveryId)
|
||||
|| envelope.DeliveryId.Length > 128
|
||||
|| string.IsNullOrWhiteSpace(envelope.EventType)
|
||||
|| envelope.EventType.Length > 64
|
||||
|| envelope.OccurredAt == null
|
||||
|| string.IsNullOrWhiteSpace(envelope.Source)
|
||||
|| envelope.Source.Length > 128
|
||||
|| envelope.Replay == null
|
||||
|| envelope.Data == null
|
||||
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
|
||||
|| envelope.Data.WorkOrderId.Length > 450
|
||||
|| !KnownEvents.Contains(envelope.EventType))
|
||||
return false;
|
||||
|
||||
var isComment = envelope.EventType == "work_order.comment_added";
|
||||
if (isComment
|
||||
&& (string.IsNullOrWhiteSpace(envelope.Data.CommentId)
|
||||
|| envelope.Data.CommentId.Length > 450
|
||||
|| string.IsNullOrWhiteSpace(envelope.Data.Text)))
|
||||
return false;
|
||||
|
||||
var statusSource = envelope.Data.WoStatus ?? envelope.Data.Status;
|
||||
mutation = new WorkOrderWebhookMutation
|
||||
{
|
||||
DeliveryId = envelope.DeliveryId,
|
||||
EventType = envelope.EventType,
|
||||
OccurredAt = envelope.OccurredAt.Value,
|
||||
ProcessedAt = _timeProvider.GetUtcNow(),
|
||||
BodySha256 = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant(),
|
||||
ExternalWorkOrderId = envelope.Data.WorkOrderId,
|
||||
WorkerOrderNumber = envelope.Data.WorkOrderId,
|
||||
Source = envelope.Source,
|
||||
IsStateEvent = !isComment,
|
||||
IsCancelled = envelope.EventType == "work_order.cancelled",
|
||||
Title = envelope.Data.Title,
|
||||
Description = envelope.Data.Description,
|
||||
Status = WorkOrderIngestFieldMapper.MapStatus(statusSource),
|
||||
Severity = envelope.Data.Severity,
|
||||
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity),
|
||||
Customer = envelope.Data.Customer,
|
||||
SiteCode = envelope.Data.SiteCode,
|
||||
Building = envelope.Data.Building,
|
||||
Address = envelope.Data.Address,
|
||||
DueDate = envelope.Data.DueDate?.UtcDateTime,
|
||||
DateReported = envelope.Data.DateReported?.UtcDateTime,
|
||||
ScheduledStart = envelope.Data.ScheduledStart?.UtcDateTime,
|
||||
CreatedAt = envelope.Data.CreatedAt?.UtcDateTime,
|
||||
CommentId = isComment ? envelope.Data.CommentId : null,
|
||||
CommentText = isComment ? envelope.Data.Text : null,
|
||||
Commenter = isComment ? envelope.Data.Commenter : null,
|
||||
CommentType = isComment ? envelope.Data.CommentType : null
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static WorkOrderWebhookResult Unauthorized() =>
|
||||
new(WorkOrderWebhookStatus.Unauthorized);
|
||||
|
||||
private static readonly HashSet<string> KnownEvents = new(StringComparer.Ordinal)
|
||||
{
|
||||
"work_order.created",
|
||||
"work_order.updated",
|
||||
"work_order.cancelled",
|
||||
"work_order.comment_added"
|
||||
};
|
||||
|
||||
private sealed class WorkOrderWebhookEnvelope
|
||||
{
|
||||
[JsonPropertyName("schema_version")]
|
||||
public int? SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("delivery_id")]
|
||||
public string? DeliveryId { get; set; }
|
||||
|
||||
[JsonPropertyName("event_type")]
|
||||
public string? EventType { get; set; }
|
||||
|
||||
[JsonPropertyName("occurred_at")]
|
||||
public DateTimeOffset? OccurredAt { get; set; }
|
||||
|
||||
public string? Source { get; set; }
|
||||
public bool? Replay { get; set; }
|
||||
public WorkOrderWebhookData? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class WorkOrderWebhookData
|
||||
{
|
||||
[JsonPropertyName("work_order_id")]
|
||||
public string? WorkOrderId { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("wo_status")]
|
||||
public string? WoStatus { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public string? Customer { get; set; }
|
||||
|
||||
[JsonPropertyName("site_code")]
|
||||
public string? SiteCode { get; set; }
|
||||
|
||||
public string? Building { get; set; }
|
||||
public string? Address { get; set; }
|
||||
|
||||
[JsonPropertyName("due_date")]
|
||||
public DateTimeOffset? DueDate { get; set; }
|
||||
|
||||
[JsonPropertyName("date_reported")]
|
||||
public DateTimeOffset? DateReported { get; set; }
|
||||
|
||||
[JsonPropertyName("scheduled_start")]
|
||||
public DateTimeOffset? ScheduledStart { get; set; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("comment_id")]
|
||||
public string? CommentId { get; set; }
|
||||
|
||||
public string? Text { get; set; }
|
||||
public string? Commenter { get; set; }
|
||||
|
||||
[JsonPropertyName("comment_type")]
|
||||
public string? CommentType { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderWebhookSecretProvider
|
||||
{
|
||||
Task<WorkOrderWebhookSecretResult> GetSecretAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public enum WorkOrderWebhookSecretStatus
|
||||
{
|
||||
Found,
|
||||
UnknownKey,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public sealed record WorkOrderWebhookSecretResult(
|
||||
WorkOrderWebhookSecretStatus Status,
|
||||
byte[]? Secret = null);
|
||||
}
|
||||
32
SeaHaven.Services/Interfaces/IWorkOrderWebhookService.cs
Normal file
32
SeaHaven.Services/Interfaces/IWorkOrderWebhookService.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderWebhookService
|
||||
{
|
||||
int MaximumBodyBytes { get; }
|
||||
|
||||
Task<WorkOrderWebhookResult> ProcessAsync(
|
||||
WorkOrderWebhookRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record WorkOrderWebhookRequest(
|
||||
string? Timestamp,
|
||||
string? KeyId,
|
||||
string? Signature,
|
||||
byte[] Body);
|
||||
|
||||
public enum WorkOrderWebhookStatus
|
||||
{
|
||||
Applied,
|
||||
Duplicate,
|
||||
Unauthorized,
|
||||
Disabled,
|
||||
InvalidEnvelope,
|
||||
HashConflict,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public sealed record WorkOrderWebhookResult(
|
||||
WorkOrderWebhookStatus Status,
|
||||
bool StateMutationSkipped = false);
|
||||
}
|
||||
441
SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs
Normal file
441
SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
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 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","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,
|
||||
KeySecrets = new Dictionary<string, string> { ["current"] = "unused" }
|
||||
}),
|
||||
new FixedTimeProvider(Now),
|
||||
NullLogger<WorkOrderWebhookService>.Instance);
|
||||
|
||||
private static byte[] ValidBody(
|
||||
string deliveryId = "delivery-1",
|
||||
string workOrderId = "WO-123") =>
|
||||
Encoding.UTF8.GetBytes(
|
||||
"{\"schema_version\":1,\"delivery_id\":\"" + deliveryId
|
||||
+ "\",\"event_type\":\"work_order.created\","
|
||||
+ "\"occurred_at\":\"2026-07-24T11:59:00Z\",\"source\":\"procurement\","
|
||||
+ "\"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();
|
||||
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();
|
||||
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"
|
||||
};
|
||||
|
||||
await data.ApplyAsync(mutation, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
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,
|
||||
ProcessedAt = occurredAt.AddMinutes(1),
|
||||
BodySha256 = bodyHash,
|
||||
ExternalWorkOrderId = "123",
|
||||
WorkerOrderNumber = "00000000123",
|
||||
Source = "procurement",
|
||||
IsStateEvent = eventType != "work_order.comment_added",
|
||||
Title = title,
|
||||
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 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue