YARA-1259

This commit is contained in:
SepehrYahyaee
2026-09-01 14:13:52 +03:30
parent a33466025d
commit 6880de5960
5 changed files with 234 additions and 13 deletions

View File

@@ -2558,11 +2558,21 @@ export class ExpertClaimService {
if (!blame) { if (!blame) {
throw new NotFoundException("Linked blame file not found."); throw new NotFoundException("Linked blame file not found.");
} }
if (!(blame as any).isMadeByFileMaker) { const clientKey = requireActorClientKey(actor);
if (!blameCaseTouchesClient(blame, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
if (
!(blame as any).isMadeByFileMaker ||
!(blame as any).expertInitiated ||
(blame as any).creationMethod !== "IN_PERSON"
) {
throw new BadRequestException({ throw new BadRequestException({
success: false, success: false,
status: "unavailable" satisfies ExpertFileAssignStatus, status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Only V4 FileMaker files can be assigned to a FileReviewer.", message: "Only V4/V5 FileMaker files can be assigned to a FileReviewer.",
}); });
} }
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") { if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
@@ -2674,6 +2684,12 @@ export class ExpertClaimService {
if (!reviewerBlame) { if (!reviewerBlame) {
throw new NotFoundException("Linked blame file not found."); throw new NotFoundException("Linked blame file not found.");
} }
const clientKey = requireActorClientKey(actor);
if (!blameCaseTouchesClient(reviewerBlame, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId) ? String((reviewerBlame as any).assignedFileReviewerId)
: null; : null;
@@ -4773,6 +4789,12 @@ export class ExpertClaimService {
"FileReviewers can only access V4/V5 FileMaker files.", "FileReviewers can only access V4/V5 FileMaker files.",
); );
} }
const clientKey = requireActorClientKey(actor);
if (!blameCaseTouchesClient(linkedBlame, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
? String((linkedBlame as any).assignedFileReviewerId) ? String((linkedBlame as any).assignedFileReviewerId)
: null; : null;

View File

@@ -86,8 +86,9 @@ export class FileReviewerBlameV4Controller {
@Get("my-files") @Get("my-files")
@ApiOperation({ @ApiOperation({
summary: "List all blame files assigned to this FileReviewer", summary: "List available and assigned FileMaker blame files",
description: "Returns all V4 FileMaker blame files that have been assigned to the authenticated FileReviewer.", description:
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
}) })
async getMyFiles(@CurrentUser() fileReviewer: any) { async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer); return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
@@ -96,8 +97,9 @@ export class FileReviewerBlameV4Controller {
@Get("my-files/:requestId") @Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({ @ApiOperation({
summary: "Get a single blame file assigned to this FileReviewer", summary: "Get one available or assigned FileMaker blame file",
description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V4 blame file.", description:
"Returns full detail for a V4 FileMaker blame file in this reviewer's insurer when it is still available to claim or is assigned to the authenticated FileReviewer.",
}) })
async getMyFileDetail( async getMyFileDetail(
@Param("requestId") requestId: string, @Param("requestId") requestId: string,

View File

@@ -84,8 +84,9 @@ export class FileReviewerBlameV5Controller {
@Get("my-files") @Get("my-files")
@ApiOperation({ @ApiOperation({
summary: "List all blame files assigned to this FileReviewer", summary: "List available and assigned FileMaker blame files",
description: "Returns all V5 FileMaker blame files that have been assigned to the authenticated FileReviewer.", description:
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
}) })
async getMyFiles(@CurrentUser() fileReviewer: any) { async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer); return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
@@ -94,8 +95,9 @@ export class FileReviewerBlameV5Controller {
@Get("my-files/:requestId") @Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({ @ApiOperation({
summary: "Get a single blame file assigned to this FileReviewer", summary: "Get one available or assigned FileMaker blame file",
description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V5 blame file.", description:
"Returns full detail for a V5 FileMaker blame file in this reviewer's insurer when it is still available to claim or is assigned to the authenticated FileReviewer.",
}) })
async getMyFileDetail( async getMyFileDetail(
@Param("requestId") requestId: string, @Param("requestId") requestId: string,

View File

@@ -0,0 +1,140 @@
import { Types } from "mongoose";
import { ExpertClaimService } from "src/expert-claim/expert-claim.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
describe("RequestManagementService FileReviewer inbox", () => {
const reviewerId = new Types.ObjectId();
const otherReviewerId = new Types.ObjectId();
const clientId = new Types.ObjectId();
const otherClientId = new Types.ObjectId();
const sealedFile = {
_id: new Types.ObjectId(),
publicId: "BLM-OPEN",
type: "THIRD_PARTY",
status: "WAITING_FOR_FILE_REVIEWER",
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
parties: [
{
role: "FIRST",
person: { clientId, userId: new Types.ObjectId() },
},
],
expert: { decision: { guiltyPartyId: new Types.ObjectId() } },
};
function createService(files: any[]) {
const blameRequestDbService = {
find: jest.fn().mockResolvedValue(files),
};
const service = new (RequestManagementService as any)(
undefined,
blameRequestDbService,
) as RequestManagementService;
return { service, blameRequestDbService };
}
it("lists a FileMaker-sealed, unassigned file for a reviewer in the same tenant", async () => {
const { service, blameRequestDbService } = createService([sealedFile]);
const result = await service.getMyFileReviewerFiles({
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
});
expect(result).toEqual([
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
]);
expect(blameRequestDbService.find).toHaveBeenCalledWith(
expect.objectContaining({
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
$or: expect.arrayContaining([
expect.objectContaining({ status: "WAITING_FOR_FILE_REVIEWER" }),
expect.objectContaining({ assignedFileReviewerId: reviewerId }),
]),
}),
);
});
it("does not list another tenant's open file or a file assigned to another reviewer", async () => {
const { service } = createService([
{
...sealedFile,
_id: new Types.ObjectId(),
parties: [{ role: "FIRST", person: { clientId: otherClientId } }],
},
{
...sealedFile,
_id: new Types.ObjectId(),
assignedFileReviewerId: otherReviewerId,
},
]);
const result = await service.getMyFileReviewerFiles({
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
});
expect(result).toEqual([]);
});
it("does not expose an open file's details to a reviewer from another tenant", async () => {
const { service } = createService([]);
(service as any).blameRequestDbService.findById = jest.fn().mockResolvedValue({
...sealedFile,
parties: [{ role: "FIRST", person: { clientId: otherClientId } }],
});
await expect(
service.getMyFileReviewerFileDetail(
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
String(sealedFile._id),
),
).rejects.toThrow("does not belong to your organization");
});
it("does not let a reviewer claim another tenant's file through its linked claim ID", async () => {
const blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
...sealedFile,
parties: [{ role: "FIRST", person: { clientId: otherClientId } }],
}),
findOneAndUpdate: jest.fn(),
};
const expertClaimService = new (ExpertClaimService as any)(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
blameRequestDbService,
) as ExpertClaimService;
await expect(
(expertClaimService as any).assignFileReviewerToV4Blame(
String(new Types.ObjectId()),
{ blameRequestId: sealedFile._id },
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
),
).rejects.toThrow("does not belong to your organization");
expect(blameRequestDbService.findOneAndUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -109,6 +109,10 @@ import {
buildBlamePartyAccessOrConditions, buildBlamePartyAccessOrConditions,
collectUserIdVariants, collectUserIdVariants,
} from "src/helpers/party-access-queries"; } from "src/helpers/party-access-queries";
import {
blameCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver";
import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service";
@@ -4671,6 +4675,12 @@ export class RequestManagementService {
"FileReviewer can only access V4/V5 FileMaker files.", "FileReviewer can only access V4/V5 FileMaker files.",
); );
} }
const clientKey = requireActorClientKey(expert);
if (!blameCaseTouchesClient(req, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
if ( if (
req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER && req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER &&
req.status !== CaseStatus.WAITING_FOR_EXPERT && req.status !== CaseStatus.WAITING_FOR_EXPERT &&
@@ -11415,12 +11425,41 @@ export class RequestManagementService {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint."); throw new ForbiddenException("Only FileReviewers can use this endpoint.");
} }
const clientKey = requireActorClientKey(fileReviewer);
const reviewerId = new Types.ObjectId(fileReviewer.sub); const reviewerId = new Types.ObjectId(fileReviewer.sub);
const files = await this.blameRequestDbService.find({ const files = await this.blameRequestDbService.find({
isMadeByFileMaker: true, isMadeByFileMaker: true,
assignedFileReviewerId: reviewerId, expertInitiated: true,
creationMethod: CreationMethod.IN_PERSON,
$or: [
// A FileMaker-sealed file must be discoverable before a reviewer can
// claim it. Once another reviewer takes it, only that reviewer sees it.
{
status: CaseStatus.WAITING_FOR_FILE_REVIEWER,
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
{ assignedFileReviewerId: reviewerId },
],
}); });
return (files || []).map((f: any) => ({ const visibleFiles = (files || []).filter((file: any) => {
const assignedReviewerId = file.assignedFileReviewerId
? String(file.assignedFileReviewerId)
: null;
const isOpen =
file.status === CaseStatus.WAITING_FOR_FILE_REVIEWER &&
!assignedReviewerId;
const isAssignedToReviewer =
assignedReviewerId === String(fileReviewer.sub);
return (
blameCaseTouchesClient(file, clientKey) &&
(isOpen || isAssignedToReviewer)
);
});
return visibleFiles.map((f: any) => ({
_id: f._id, _id: f._id,
publicId: f.publicId, publicId: f.publicId,
requestNo: f.requestNo, requestNo: f.requestNo,
@@ -11442,17 +11481,33 @@ export class RequestManagementService {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint."); throw new ForbiddenException("Only FileReviewers can use this endpoint.");
} }
const clientKey = requireActorClientKey(fileReviewer);
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found"); if (!req) throw new NotFoundException("Blame request not found");
if (!req.isMadeByFileMaker) { if (
!req.isMadeByFileMaker ||
!req.expertInitiated ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files."); throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files.");
} }
if (!blameCaseTouchesClient(req, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
const assignedId = (req as any).assignedFileReviewerId const assignedId = (req as any).assignedFileReviewerId
? String((req as any).assignedFileReviewerId) ? String((req as any).assignedFileReviewerId)
: null; : null;
if (assignedId && assignedId !== String(fileReviewer.sub)) { if (assignedId && assignedId !== String(fileReviewer.sub)) {
throw new ForbiddenException("This file has been taken by another FileReviewer."); throw new ForbiddenException("This file has been taken by another FileReviewer.");
} }
if (
!assignedId &&
req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER
) {
throw new ForbiddenException("This file is not available for review.");
}
const plain = typeof (req as any).toObject === "function" const plain = typeof (req as any).toObject === "function"
? (req as any).toObject({ versionKey: false }) ? (req as any).toObject({ versionKey: false })
: { ...(req as any) }; : { ...(req as any) };