mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
6 commits
b3996cf5d2
...
4080d79723
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4080d79723 | ||
|
|
deeb29b512 | ||
|
|
76c606eb75 | ||
|
|
6212949fff | ||
|
|
af6faf77e3 | ||
|
|
7e4db749d0 |
6 changed files with 202 additions and 13 deletions
|
|
@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
|
|
@ -109,6 +110,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
string? fileUrl = null;
|
||||
try
|
||||
{
|
||||
WorkOrderCompletionDocFileRules.EnsureAllowed(file);
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, User, actorId);
|
||||
|
||||
|
|
|
|||
70
SeaHaven.Services/Helpers/WorkOrderCompletionDocFileRules.cs
Normal file
70
SeaHaven.Services/Helpers/WorkOrderCompletionDocFileRules.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>SH-337 file type allowlist for work-order completion documents.</summary>
|
||||
public static class WorkOrderCompletionDocFileRules
|
||||
{
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
private static readonly HashSet<string> AllowedExtensions =
|
||||
new(StringComparer.OrdinalIgnoreCase) { ".pdf" };
|
||||
|
||||
// A browser sets the multipart part's Content-Type from File.type, which the OS
|
||||
// sometimes leaves empty for a PDF. The completion-doc dialog already accepts
|
||||
// that case on the client (a .pdf name with no type passes), so rejecting it
|
||||
// here would fail uploads that work today. The %PDF- signature is the real gate.
|
||||
private static readonly HashSet<string> UndeterminedContentTypes =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "application/octet-stream" };
|
||||
|
||||
public static bool IsAllowed(IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length <= 0)
|
||||
return false;
|
||||
|
||||
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(declaredType)
|
||||
&& !declaredType.Equals(PdfContentType, StringComparison.OrdinalIgnoreCase)
|
||||
&& !UndeterminedContentTypes.Contains(declaredType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(extension) || !AllowedExtensions.Contains(extension))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64);
|
||||
if (headerLength == 0)
|
||||
return false;
|
||||
|
||||
var header = new byte[headerLength];
|
||||
var read = stream.Read(header, 0, header.Length);
|
||||
if (read <= 0)
|
||||
return false;
|
||||
|
||||
if (read < header.Length)
|
||||
Array.Resize(ref header, read);
|
||||
|
||||
return WorkOrderMediaFileRules.MatchesSignature(PdfContentType, header);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureAllowed(IFormFile file)
|
||||
{
|
||||
if (!IsAllowed(file))
|
||||
{
|
||||
throw new Exceptions.WorkOrderBoardValidationException(
|
||||
"UnsupportedMediaType",
|
||||
"The completion document must be a PDF file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ using System.Text.Json;
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
|
|
@ -13,10 +12,7 @@ namespace SeaHaven.Services.Helpers
|
|||
|
||||
public static void Capture(WorkOrder workOrder)
|
||||
{
|
||||
var location = workOrder.Locations
|
||||
?? throw new WorkOrderBoardValidationException(
|
||||
"CompletionSnapshotUnavailable",
|
||||
"Site details are required before marking the work order Completed.");
|
||||
var location = workOrder.Locations;
|
||||
|
||||
var contact = workOrder.WorkOrderContacts?
|
||||
.OrderBy(item => item.Id)
|
||||
|
|
@ -39,14 +35,14 @@ namespace SeaHaven.Services.Helpers
|
|||
new WorkOrderFrozenSite
|
||||
{
|
||||
Code = workOrder.SiteCode,
|
||||
Label = FirstNotBlank(location.Name, location.Title, workOrder.SiteCode),
|
||||
Address = JoinAddress(location.Address1, location.Address2),
|
||||
City = location.City,
|
||||
State = location.State,
|
||||
Zip = location.Zip,
|
||||
Phone = location.PhoneNumber,
|
||||
Email = location.Email,
|
||||
Maps = BuildMapsUrl(location)
|
||||
Label = FirstNotBlank(location?.Name, location?.Title, workOrder.SiteCode),
|
||||
Address = JoinAddress(location?.Address1, location?.Address2),
|
||||
City = location?.City,
|
||||
State = location?.State,
|
||||
Zip = location?.Zip,
|
||||
Phone = location?.PhoneNumber,
|
||||
Email = location?.Email,
|
||||
Maps = location == null ? null : BuildMapsUrl(location)
|
||||
},
|
||||
JsonOptions);
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,14 @@ namespace SeaHaven.Services.Helpers
|
|||
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
}
|
||||
|
||||
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// %PDF-
|
||||
return bytes.Length >= 5
|
||||
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44
|
||||
&& bytes[3] == 0x46 && bytes[4] == 0x2D;
|
||||
}
|
||||
|
||||
if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -117,6 +117,38 @@ public class WorkOrderCompletionFreezeTests
|
|||
Assert.Equal("Frozen POC", afterFlag.PocName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstCompletedTransitionWithoutSiteStillCapturesAvailableSnapshot()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var service = CreateService(context);
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000012345",
|
||||
SiteCode = "BK5",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(1),
|
||||
RowVersion = Version()
|
||||
};
|
||||
context.Add(workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var completed = await service.PatchFieldAsync(
|
||||
workOrder.Id,
|
||||
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
|
||||
"actor-1");
|
||||
var persisted = await context.workOrders.SingleAsync();
|
||||
|
||||
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
|
||||
Assert.Equal("BK5", completed.FrozenSite?.Code);
|
||||
Assert.Equal("BK5", completed.FrozenSite?.Label);
|
||||
Assert.Null(completed.FrozenSite?.City);
|
||||
Assert.Null(completed.FrozenCompany);
|
||||
Assert.False(string.IsNullOrWhiteSpace(persisted.FrozenSite));
|
||||
Assert.Null(persisted.FrozenCompany);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWorkOrderProjectionKeepsUsingLiveLocation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1865,6 +1865,87 @@ public class WorkOrderMediaServiceTests
|
|||
}
|
||||
}
|
||||
|
||||
public class WorkOrderCompletionDocFileRulesTests
|
||||
{
|
||||
private static readonly byte[] Pdf = Encoding.ASCII.GetBytes("%PDF-1.7\n1 0 obj\n");
|
||||
|
||||
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
|
||||
=> new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = contentType
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_ValidPdf_ReturnsTrue()
|
||||
{
|
||||
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/pdf")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_PdfWithUndeterminedContentType_ReturnsTrue()
|
||||
{
|
||||
// The browser leaves File.type empty when the OS cannot determine it, and the
|
||||
// completion-doc dialog already lets that through on a .pdf name. Rejecting it
|
||||
// would break uploads that work today.
|
||||
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "")));
|
||||
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/octet-stream")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_PdfExtensionWithNonPdfContent_ReturnsFalse()
|
||||
{
|
||||
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
|
||||
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(html, "completion.pdf", "application/pdf")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_PdfContentUnderDisallowedExtension_ReturnsFalse()
|
||||
{
|
||||
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.exe", "application/pdf")));
|
||||
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion", "application/pdf")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_DisallowedContentType_ReturnsFalse()
|
||||
{
|
||||
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
|
||||
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
|
||||
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "text/html")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_EmptyFile_ReturnsFalse()
|
||||
{
|
||||
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Array.Empty<byte>(), "completion.pdf", "application/pdf")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureAllowed_RejectedFile_ThrowsUnsupportedMediaType()
|
||||
{
|
||||
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
|
||||
var ex = Assert.Throws<WorkOrderBoardValidationException>(
|
||||
() => WorkOrderCompletionDocFileRules.EnsureAllowed(FormFile(html, "completion.pdf", "application/pdf")));
|
||||
Assert.Equal("UnsupportedMediaType", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaRules_StillRejectPdfOutsideDocumentCategories()
|
||||
{
|
||||
// The completion-doc allowlist must not widen the SH-116 media allowlist.
|
||||
// SH-171 since added documents to media for Extra and Aveta, which is a
|
||||
// deliberate widening of its own; what this guards is that it stopped
|
||||
// there. Completion is the category SH-337 touches, and a photo slot
|
||||
// must still refuse a PDF.
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Completion));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Before));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.After));
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderMediaFileRulesTests
|
||||
{
|
||||
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue