shoc-backend/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Alexandre Brandizzi 7d245eb717
refactor: enforce backend boundaries and optimize dispatch (#30)
* refactor(api): enforce service and data-service boundaries

* refactor(api): complete feature service boundaries

* refactor(identity): enforce service and data boundaries

* refactor(vendors): enforce service and data boundaries

* refactor(workorders): enforce service and data boundaries

* refactor(backend): enforce architecture and optimize dispatch

* style(backend): format changed architecture files

* fix(architecture): address backend review follow-ups

* fix(backend): sanitize exception disclosure in changed API endpoints

Replace raw exception-message disclosure (ex.Message) returned to API
callers with a stable sanitized public message plus correlated structured
internal logging, across the endpoints changed in this PR.

- Add SanitizedErrors helper: logs the original exception at Error with a
  generated correlation id and returns a stable public message referencing
  it so support can trace without exposing internals.
- Inject ILogger<T> into the 14 changed controllers and route every
  ex.Message/dbex.Message disclosure through the helper, preserving status
  codes, response shapes, and business data (e.g. OpenWorkOrders).
- Leave FluentValidation (vex.Errors) and existing fixed-message catches
  untouched; out-of-scope controllers (Account/Contact/Employee/Asset/
  PMSchedule) are unchanged.
- Add focused tests proving internal exception text is not returned and
  that Error logging carrying the original exception is invoked.

* fix(architecture): abstract job run state access

* style: format board update service

* test: use collection assertion idiom
2026-07-24 17:35:34 -03:00

179 lines
6.6 KiB
C#

using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class VendorPortalDocumentTests : IDisposable
{
private const string Token = "vendor-document-test-token";
private readonly string _contentRoot = Path.Combine(
Path.GetTempPath(),
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private VendorPortalController NewController(ApplicationDbContext context)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
vendorData,
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
var documentData = new VendorDocumentDataService(context);
var storageEnvironment = new Mock<IWebHostEnvironment>();
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
var service = new VendorPortalService(
tokenService,
new DispatchDataService(context),
Mock.Of<IUpliftDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IEmailSender>(),
documentData,
storage,
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
controller.Request.Headers["X-Vendor-Token"] = Token;
return controller;
}
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
ApplicationDbContext context)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(dispatch);
context.VendorAccessTokens.Add(new VendorAccessToken
{
VendorId = vendor.Id,
Token = Token,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(1)
});
await context.SaveChangesAsync();
return (vendor, dispatch);
}
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
{
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var upload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
upload.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing");
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
var locked = download.Should().BeOfType<ObjectResult>().Subject;
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "Application/PDF"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(png, "photo.png", "Image/PNG"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}