fix(work-orders): scope comments and completion-doc by account [SH-221]

Close the remaining SH-221 bypass: board/legacy comments and completion-doc now enforce server-derived account scope, authorize before blob storage, and cover cross-account regressions.
This commit is contained in:
Arthur Bassi 2026-08-11 14:52:02 -03:00
parent 62a4828e2f
commit 3c090e2757
14 changed files with 454 additions and 45 deletions

View file

@ -106,10 +106,13 @@ namespace Api.SeaHavenIndustries.Controllers
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
string? fileUrl = null;
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, User, actorId);
fileUrl = await _fileStorage.SaveFileAsync(file);
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
id,
new WorkOrderCompletionDocUploadDto
@ -119,21 +122,37 @@ namespace Api.SeaHavenIndustries.Controllers
WorkOrderVersion = workOrderVersion
},
fileUrl,
User,
actorId);
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
{
TryCompensateUpload(fileUrl);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "Forbidden")
{
TryCompensateUpload(fileUrl);
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
{
TryCompensateUpload(fileUrl);
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
TryCompensateUpload(fileUrl);
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
private void TryCompensateUpload(string? fileUrl)
{
if (!string.IsNullOrWhiteSpace(fileUrl))
_fileStorage.TryDelete(fileUrl);
}
}
}

View file

@ -318,9 +318,17 @@ namespace Api.SeaHavenIndustries.Controllers
Document = model.Document
};
var saved = await _workOrderService.AddCommentAsync(input, userId!);
var saved = await _workOrderService.AddCommentAsync(input, User, userId!);
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new Response { Status = "Error", Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -341,9 +349,17 @@ namespace Api.SeaHavenIndustries.Controllers
CommentType = model.CommentType
};
var result = await _workOrderService.AddCommentJsonAsync(input, userId!);
var result = await _workOrderService.AddCommentJsonAsync(input, User, userId!);
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new Response { Status = "Error", Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -362,8 +378,17 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("GetCommentsByWorkorderId")]
public async Task<IActionResult> GetCommentsByWorkorderId(int woid)
{
var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid);
return Ok(data);
try
{
var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid, User);
if (data == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(data);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]

View file

@ -61,10 +61,18 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id:int}/comments")]
public async Task<IActionResult> GetBoardComments(int id)
{
var comments = await _workOrderCommentService.GetCommentsAsync(id);
if (comments == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(comments);
try
{
var comments = await _workOrderCommentService.GetCommentsAsync(id, User);
if (comments == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(comments);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id:int}/comments")]
@ -73,13 +81,18 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId);
var comment = await _workOrderCommentService.AddCommentAsync(id, request, User, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
@ -96,7 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.UpdateCommentAsync(
id, commentId, request, actorId);
id, commentId, request, User, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -13,10 +14,29 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
cancellationToken);
public Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;

View file

@ -4,7 +4,16 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderCompletionDataService
{
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
@ -12,20 +13,26 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly ICommentDataService _commentData;
private readonly IUserDataService _userDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderCommentService(
IWorkOrderDetailDataService detailData,
ICommentDataService commentData,
IUserDataService userDataService)
IUserDataService userDataService,
IWorkOrderAccountResolver accountResolver)
{
_detailData = detailData;
_commentData = commentData;
_userDataService = userDataService;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(
int workOrderId,
ClaimsPrincipal user)
{
if (!await _detailData.ExistsAsync(workOrderId))
var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
return null;
var comments = await _detailData.GetCommentsAsync(workOrderId);
@ -36,10 +43,12 @@ namespace SeaHaven.Services.Implementation
public async Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId,
string? documentUrl = null)
{
if (!await _detailData.ExistsAsync(workOrderId))
var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (string.IsNullOrWhiteSpace(request.Text))
@ -65,9 +74,12 @@ namespace SeaHaven.Services.Implementation
int workOrderId,
int commentId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId)
{
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId, CancellationToken.None, accountId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
@ -14,17 +15,20 @@ namespace SeaHaven.Services.Implementation
private readonly ICompletionDocTemplateDataService _templateData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderCompletionService(
IWorkOrderCompletionDataService completionData,
ICompletionDocTemplateDataService templateData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService)
IWorkOrderAuditService auditService,
IWorkOrderAccountResolver accountResolver)
{
_completionData = completionData;
_templateData = templateData;
_detailData = detailData;
_auditService = auditService;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
@ -39,13 +43,34 @@ namespace SeaHaven.Services.Implementation
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
}
public async Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(actorId) || user?.Identity?.IsAuthenticated != true)
{
throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to upload completion documents.");
}
// AsNoTracking pre-check so UploadCompletionDocAsync load is not stale-cached.
await GetMutableWorkOrderForAuthAsync(workOrderId, user, cancellationToken);
}
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId)
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _completionData.GetTrackedWorkOrderAsync(
workOrderId, accountId, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
@ -77,7 +102,7 @@ namespace SeaHaven.Services.Implementation
if (oldDocStatus != DocStatus.Yes.ToString())
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
await _completionData.SaveAsync(CancellationToken.None);
await _completionData.SaveAsync(cancellationToken);
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
@ -143,6 +168,23 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
}
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _completionData.GetWorkOrderForCompletionAuthAsync(
workOrderId, accountId, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
return workOrder;
}
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))

View file

@ -5,6 +5,7 @@ using FluentValidation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
@ -439,8 +440,10 @@ namespace SeaHaven.Services.Implementation
return true;
}
public async Task<Comments> AddCommentAsync(AddCommentInput input, string userId)
public async Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId)
{
await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user);
var comment = new Comments
{
UserId = userId,
@ -458,8 +461,10 @@ namespace SeaHaven.Services.Implementation
return await _commentDataService.AddAsync(comment);
}
public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId)
public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId)
{
await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user);
var comment = new Comments
{
UserId = userId,
@ -469,7 +474,7 @@ namespace SeaHaven.Services.Implementation
};
var saved = await _commentDataService.AddAsync(comment);
var user = await _userDataService.GetByIdAsync(userId);
var displayUser = await _userDataService.GetByIdAsync(userId);
return new CommentResult
{
@ -477,7 +482,7 @@ namespace SeaHaven.Services.Implementation
CreatedDate = saved.CreatedDate,
Commenttext = saved.Commenttext,
CommentType = saved.CommentType,
UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""
UserName = displayUser != null ? (displayUser.FirstName + " " + displayUser.LastName).Trim() : ""
};
}
@ -493,8 +498,15 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid)
public async Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(
int woid,
ClaimsPrincipal user)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(woid, accountId);
if (workOrder == null)
return null;
var comments = await _commentDataService.GetByWorkOrderIdAsync(woid);
return comments.Select(s => new CommentListItemReadModel
{
@ -505,6 +517,14 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
private async Task EnsureWorkOrderInAccountScopeAsync(int workOrderId, ClaimsPrincipal user)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(workOrderId, accountId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null)
{
var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);

View file

@ -1,16 +1,22 @@
using Data.SeaHavenIndustries.Enums;
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCommentService
{
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId, ClaimsPrincipal user);
Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId,
string? documentUrl = null);
Task<WorkOrderCommentDto> UpdateCommentAsync(
int workOrderId,
int commentId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId);
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
@ -7,11 +8,18 @@ namespace SeaHaven.Services.Interfaces
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId);
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);

View file

@ -37,10 +37,10 @@ namespace SeaHaven.Services.Interfaces
Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId);
Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId);
Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId);
Task<Comments> AddCommentAsync(AddCommentInput input, string userId);
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId);
Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync();
Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid);
Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(int woid, ClaimsPrincipal user);
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);
}

View file

@ -429,4 +429,211 @@ public class WorkOrderAccountScopeTests
});
await context.SaveChangesAsync();
}
[Fact]
public async Task Comments_CrossAccount_GetAddUpdate_ReturnNotFound()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.Users.Add(new ApplicationUser
{
Id = "author-1",
UserName = "author",
FirstName = "Ann",
LastName = "Author"
});
context.workOrders.Add(new WorkOrder
{
Id = 20,
InternalWONumber = "00000000020",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
context.Comments.Add(new Comments
{
Id = 5,
WorkerOrderId = 20,
UserId = "author-1",
Commenttext = "Secret",
CommentType = "General",
RecordType = "WorkOrder",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
Assert.Null(await service.GetCommentsAsync(20, scoped));
var addEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddCommentAsync(20, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "disp-1"));
Assert.Equal("NotFound", addEx.Code);
var updateEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
20, 5, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "author-1"));
Assert.Equal("NotFound", updateEx.Code);
}
[Fact]
public async Task Comments_SameAccount_AddSucceeds()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
context.Users.Add(new ApplicationUser
{
Id = "disp-1",
UserName = "disp",
FirstName = "Dee",
LastName = "Spatch"
});
context.workOrders.Add(new WorkOrder
{
Id = 21,
InternalWONumber = "00000000021",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var result = await service.AddCommentAsync(
21, new WorkOrderCommentCreateDto { Text = "In scope" }, scoped, "disp-1");
Assert.Equal("In scope", result.Text);
Assert.Equal("disp-1", result.AuthorId);
}
[Fact]
public async Task LegacyCommentsByWorkOrder_CrossAccount_ReturnsNull()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
context.Comments.Add(new Comments
{
WorkerOrderId = 2,
Commenttext = "Other account",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = CreateLegacyReadService(context);
var result = await service.GetCommentsByWorkorderIdAsync(
2, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Null(result);
}
[Fact]
public async Task CompletionDoc_CrossAccount_EnsureAndUpload_ThrowNotFound()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.Add(new WorkOrder
{
Id = 30,
InternalWONumber = "00000000030",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 },
istemplate = false
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var ensureEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanUploadCompletionDocAsync(30, scoped, "disp-1"));
Assert.Equal("NotFound", ensureEx.Code);
var uploadEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UploadCompletionDocAsync(
30,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/should-not-stick.pdf",
scoped,
"disp-1"));
Assert.Equal("NotFound", uploadEx.Code);
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 30);
Assert.Null(wo.SignOffAttachment);
Assert.Equal(DocStatus.No, wo.DocStatus);
}
[Fact]
public async Task CompletionDoc_SameAccount_EnsureAllowsUpload()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
var rowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
context.workOrders.Add(new WorkOrder
{
Id = 31,
InternalWONumber = "00000000031",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = rowVersion,
istemplate = false
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
await service.EnsureCanUploadCompletionDocAsync(31, scoped, "disp-1");
var result = await service.UploadCompletionDocAsync(
31,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(rowVersion)
},
"https://example.com/ok.pdf",
scoped,
"disp-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment);
}
}

View file

@ -247,7 +247,12 @@ public class WorkOrderCompletionServiceTests
var detailData = new WorkOrderDetailDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(completionData, templateData, detailData, audit);
var service = new WorkOrderCompletionService(
completionData,
templateData,
detailData,
audit,
WorkOrderAccountTestHelpers.Resolver(context));
return (context, service);
}
@ -274,6 +279,7 @@ public class WorkOrderCompletionServiceTests
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!)
},
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
@ -304,6 +310,7 @@ public class WorkOrderCompletionServiceTests
1,
new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version },
"https://example.com/first.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
// Simulate a new request scope: clear local tracker then reload
@ -319,6 +326,7 @@ public class WorkOrderCompletionServiceTests
WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!)
},
"https://example.com/second.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync();
@ -344,6 +352,7 @@ public class WorkOrderCompletionServiceTests
1,
new WorkOrderCompletionDocUploadDto(),
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1"));
Assert.Equal("WorkOrderVersionRequired", ex.Code);
@ -368,6 +377,7 @@ public class WorkOrderCompletionServiceTests
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/signed.pdf",
WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"),
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
@ -396,9 +406,14 @@ public class WorkOrderCommentServiceTests
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(detailData, commentData, userData);
var service = new WorkOrderCommentService(
detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
var result = await service.AddCommentAsync(
1,
new WorkOrderCommentCreateDto { Text = "Note" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-abc"),
"user-abc");
Assert.Equal("user-abc", result.AuthorId);
Assert.Equal("Bob Tech", result.AuthorName);
@ -426,9 +441,10 @@ public class WorkOrderCommentServiceTests
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(detailData, commentData, userData);
var service = new WorkOrderCommentService(
detailData, commentData, userData, WorkOrderAccountTestHelpers.Resolver(context));
var comments = await service.GetCommentsAsync(1);
var comments = await service.GetCommentsAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(comments);
Assert.Single(comments!);
@ -471,7 +487,8 @@ public class WorkOrderCommentServiceTests
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context));
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
return (context, service, comment);
}
@ -484,6 +501,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = " Updated note " },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author");
Assert.Equal("Updated note", result.Text);
@ -501,6 +519,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Admin edit" },
WorkOrderAccountTestHelpers.OrgWideAdmin("admin-user"),
"admin-user"));
Assert.Equal("Forbidden", ex.Code);
@ -516,6 +535,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("other-user"),
"other-user"));
Assert.Equal("Forbidden", ex.Code);
@ -533,6 +553,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotEditable", ex.Code);
@ -548,6 +569,7 @@ public class WorkOrderCommentServiceTests
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("ReadOnly", ex.Code);
@ -565,6 +587,7 @@ public class WorkOrderCommentServiceTests
2,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotFound", ex.Code);

View file

@ -7,8 +7,11 @@
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys (nullable; legacy null fail-closed)
- JWT `account_id` when `ApplicationUser.AccountId` is set
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
- **Reads** (board, list, advanced search, detail, media): `ApplyBaseScope` +
- **Reads** (board, list, advanced search, detail, media, board comments,
legacy comments-by-work-order-id): `ApplyBaseScope` +
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
same account filter at service/data entry; authorize before storing blobs
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId`
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
@ -37,6 +40,8 @@ in review (fail-open) and replaced by the contract below.
callers; only `org_scope=all` may read them.
7. **Authorization at service entry**: staff roles may read/mutate any resulting
work order; role `User` only when `AssignTo == actorId` (media); delete staff-only.
Board comments, legacy comments-by-WO-id, and completion-doc uploads apply the
same account filter before read/write (and before blob storage).
8. **User lifecycle** persists `AccountId` on Admin create/edit so non-Admin
principals can receive `account_id`.