mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
* 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
179 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|