diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index fab68d5..33ef432 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -101,6 +101,28 @@ public sealed class VendorPortalDocumentTests : IDisposable }; } + [Fact] + public void CompletionDocumentEndpoints_AdvertiseCanonicalAndCompatibilityRoutes() + { + var uploadRoutes = typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! + .GetCustomAttributes(typeof(HttpPostAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + var downloadRoutes = typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.DownloadCompletionDocument))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + + uploadRoutes.Should().BeEquivalentTo( + "dispatches/{id:int}/completion-documents", + "dispatches/{id:int}/documents"); + downloadRoutes.Should().BeEquivalentTo( + "dispatches/{id:int}/completion-documents/{documentId:int}", + "dispatches/{id:int}/documents/{documentId:int}"); + } + [Fact] public async Task UploadCompletionDocument_RejectsMismatchedFileSignature() { @@ -131,6 +153,7 @@ public sealed class VendorPortalDocumentTests : IDisposable var document = await context.VendorCompletionDocuments.SingleAsync(); document.ScanStatus.Should().Be("Pending"); document.ReviewStatus.Should().Be("Processing"); + document.ReplacesDocumentId.Should().BeNull(); File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue(); var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id); @@ -154,6 +177,106 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public async Task UploadCompletionDocument_UsesExplicitOwnedReplacement() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var controller = NewController(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var firstUpload = await controller.UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v1.pdf", "application/pdf")); + firstUpload.Should().BeOfType(); + var first = await context.VendorCompletionDocuments.SingleAsync(); + + var replacementUpload = await controller.UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v2.pdf", "application/pdf"), + first.Id); + + replacementUpload.Should().BeOfType(); + var replacement = await context.VendorCompletionDocuments + .OrderBy(document => document.Version) + .LastAsync(); + replacement.Version.Should().Be(2); + replacement.ReplacesDocumentId.Should().Be(first.Id); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsReplacementFromAnotherDispatch() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + var controller = NewController(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + await controller.UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v1.pdf", "application/pdf")); + var existing = await context.VendorCompletionDocuments.SingleAsync(); + + var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other repair" }; + context.Add(otherWorkOrder); + await context.SaveChangesAsync(); + var otherDispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = otherWorkOrder.Id, + Status = "Completed" + }; + context.Dispatches.Add(otherDispatch); + await context.SaveChangesAsync(); + + var result = await controller.UploadCompletionDocument( + otherDispatch.Id, + FormFile(pdf, "replacement.pdf", "application/pdf"), + existing.Id); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().ContainSingle(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsReplacementOwnedByAnotherVendor() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var otherVendor = new Vendor { CompanyName = "Other vendor", IsActive = true }; + var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other vendor repair" }; + context.AddRange(otherVendor, otherWorkOrder); + await context.SaveChangesAsync(); + var otherDispatch = new Dispatch + { + VendorId = otherVendor.Id, + WorkOrderId = otherWorkOrder.Id, + Status = "Completed" + }; + context.Dispatches.Add(otherDispatch); + await context.SaveChangesAsync(); + var otherDocument = new VendorCompletionDocument + { + VendorId = otherVendor.Id, + DispatchId = otherDispatch.Id, + WorkOrderId = otherWorkOrder.Id, + OriginalFileName = "other.pdf", + StoredFileName = "other.pdf", + ContentType = "application/pdf", + SizeBytes = 4, + Version = 1 + }; + context.VendorCompletionDocuments.Add(otherDocument); + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("%PDF-1.4\nreplacement"u8.ToArray(), "replacement.pdf", "application/pdf"), + otherDocument.Id); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().ContainSingle(); + } + [Fact] public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 447cf30..f4852ac 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -228,15 +228,25 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("dispatches/{id:int}/completion-documents")] + [HttpPost("dispatches/{id:int}/documents")] [RequestSizeLimit(10_000_000)] - public async Task UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default) + public async Task UploadCompletionDocument( + int id, + [FromForm] IFormFile file, + [FromForm] int? replacesDocumentId = null, + CancellationToken cancellationToken = default) { var session = await ResolveSessionAsync(cancellationToken); if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); try { - var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken); + var result = await _portalService.UploadCompletionDocumentAsync( + session, + id, + file, + replacesDocumentId, + cancellationToken); return Ok(new DataResponse { Status = "Success", Data = result }); } catch (KeyNotFoundException) @@ -250,6 +260,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")] + [HttpGet("dispatches/{id:int}/documents/{documentId:int}")] public async Task DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default) { var session = await ResolveSessionAsync(cancellationToken); diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 70d1ddb..b3b2362 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -510,7 +510,11 @@ namespace SeaHaven.Services.Implementation } public async Task UploadCompletionDocumentAsync( - VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken) + VendorPortalSession session, + int dispatchId, + IFormFile file, + int? replacesDocumentId, + CancellationToken cancellationToken) { if (file is null || file.Length == 0) { @@ -544,6 +548,20 @@ namespace SeaHaven.Services.Implementation } var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); + var replacedDocument = latest; + if (replacesDocumentId.HasValue) + { + replacedDocument = await _documentData.GetForVendorDispatchAsync( + replacesDocumentId.Value, + dispatchId, + session.Id, + cancellationToken); + if (replacedDocument == null) + { + throw new InvalidOperationException( + "The completion document could not be replaced."); + } + } var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; @@ -560,7 +578,7 @@ namespace SeaHaven.Services.Implementation ScanStatus = "Pending", ReviewStatus = "Processing", Version = version, - ReplacesDocumentId = latest?.Id, + ReplacesDocumentId = replacedDocument?.Id, CreatedDate = now }; @@ -570,7 +588,7 @@ namespace SeaHaven.Services.Implementation WorkOrderId = dispatch.WorkOrderId ?? 0, DispatchId = dispatchId, FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document", - OldValue = latest == null ? null : $"v{latest.Version}", + OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}", NewValue = $"v{version}", Action = "vendor_completion_document_uploaded", ActorType = "vendor", diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs index b60edfd..6c53b0c 100644 --- a/SeaHaven.Services/Interfaces/IVendorPortalService.cs +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -15,7 +15,7 @@ namespace SeaHaven.Services.Interfaces Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); - Task UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken); + Task UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, CancellationToken cancellationToken); Task DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken); } } diff --git a/scripts/smoke-elastic-beanstalk.sh b/scripts/smoke-elastic-beanstalk.sh index 703c0b0..3a7d15f 100755 --- a/scripts/smoke-elastic-beanstalk.sh +++ b/scripts/smoke-elastic-beanstalk.sh @@ -5,8 +5,8 @@ # # Checks: # 1. swagger.json is reachable (HTTP 200) -# 2. swagger advertises POST /api/webhooks/work-orders -# 3. swagger still advertises POST /api/Authentication/login +# 2. swagger advertises release-critical webhook, login, and vendor routes +# 3. protected vendor routes reject unauthenticated callers rather than 404 # 4. an unauthenticated JSON POST to the webhook returns 401 or 503 (disabled), # never 404 or a server error other than the intentional 503 # @@ -37,12 +37,53 @@ swagger_http=$(curl -sS -o .artifacts/elastic-beanstalk/swagger.json \ swagger_file=".artifacts/elastic-beanstalk/swagger.json" ok "swagger.json HTTP 200" -log "swagger advertises webhook and login routes" -grep -q '"/api/webhooks/work-orders"' "$swagger_file" \ - || die "swagger.json missing /api/webhooks/work-orders route." -grep -q '"/api/Authentication/login"' "$swagger_file" \ - || die "swagger.json missing /api/Authentication/login route." -ok "webhook and login routes present" +log "swagger advertises release-critical routes" +required_paths=( + "/api/webhooks/work-orders" + "/api/Authentication/login" + "/api/Vendor/facets" + "/api/Vendor/{id}/deactivation-impact" + "/api/vendor-operations/notifications" + "/api/vendor-operations/availability" + "/api/vendor-operations/sites/{locationId}/preferred-vendors" + "/api/vendor-operations/work-orders/{workOrderId}/assignment" + "/api/vendor-operations/insights" + "/api/vendor-operations/insights.csv" + "/api/vendor-operations/insights.pdf" + "/api/vendor-portal/dispatches/{id}/completion-documents" + "/api/vendor-portal/dispatches/{id}/completion-documents/{documentId}" + "/api/vendor-portal/dispatches/{id}/documents" + "/api/vendor-portal/dispatches/{id}/documents/{documentId}" +) +for path in "${required_paths[@]}"; do + grep -Fq "\"$path\"" "$swagger_file" \ + || die "swagger.json missing $path route." +done +ok "release-critical webhook, login, and vendor routes present" + +assert_protected_route() { + local url="$1" + local route_http + route_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || true) + case "$route_http" in + 401|403) ok "$url rejected an unauthenticated caller with HTTP $route_http." ;; + 404) die "$url returned 404 — route not wired (deployment broken)." ;; + 5*) die "$url returned HTTP $route_http — unexpected server error." ;; + *) die "$url returned HTTP $route_http — expected 401 or 403." ;; + esac +} + +log "protected vendor routes are wired" +assert_protected_route "$BASE_URL/api/Vendor/facets" +assert_protected_route "$BASE_URL/api/vendor-operations/notifications" +assert_protected_route "$BASE_URL/api/Vendor/1/deactivation-impact" + +log "vendor portal rejects a missing token" +portal_session_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ + "$BASE_URL/api/vendor-portal/session" || true) +[[ "$portal_session_http" == "401" ]] \ + || die "vendor portal session returned HTTP $portal_session_http (expected 401)." +ok "vendor portal session returned 401 without a token" log "unauthenticated webhook POST: $WEBHOOK_URL" webhook_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \