Skip to content

Commit 6498ad4

Browse files
committed
Review deletion checks for reviewers
1 parent f961e31 commit 6498ad4

File tree

3 files changed

+142
-6
lines changed

3 files changed

+142
-6
lines changed

src/api/review/review.controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,12 @@ export class ReviewController {
267267
}
268268

269269
@Delete('/items/:itemId')
270-
@Roles(UserRole.Copilot, UserRole.Admin)
270+
@Roles(UserRole.Copilot, UserRole.Reviewer, UserRole.Admin)
271271
@Scopes(Scope.DeleteReviewItem)
272272
@ApiOperation({
273273
summary: 'Delete a review item',
274-
description: 'Roles: Copilot, Admin | Scopes: delete:review-item',
274+
description:
275+
'Roles: Copilot, Reviewers (their own review items), Admin | Scopes: delete:review-item',
275276
})
276277
@ApiParam({
277278
name: 'itemId',

src/api/review/review.service.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ describe('ReviewService.createReview authorization checks', () => {
204204
} as any;
205205

206206
prismaMock.review.create.mockResolvedValue(reviewCreateResult);
207+
prismaMock.review.update.mockResolvedValue({
208+
...reviewCreateResult,
209+
initialScore: 0,
210+
finalScore: 0,
211+
});
207212

208213
await expect(
209214
service.createReview(baseAuthUser, request),
@@ -263,6 +268,11 @@ describe('ReviewService.createReview authorization checks', () => {
263268
]);
264269

265270
prismaMock.review.create.mockResolvedValue(reviewCreateResult);
271+
prismaMock.review.update.mockResolvedValue({
272+
...reviewCreateResult,
273+
initialScore: 0,
274+
finalScore: 0,
275+
});
266276

267277
await expect(
268278
service.createReview(baseAuthUser, request),
@@ -1223,3 +1233,121 @@ describe('ReviewService.deleteReview', () => {
12231233
});
12241234
});
12251235
});
1236+
1237+
describe('ReviewService.deleteReviewItem authorization checks', () => {
1238+
const prismaMock = {
1239+
reviewItem: {
1240+
findUnique: jest.fn(),
1241+
delete: jest.fn(),
1242+
},
1243+
} as unknown as any;
1244+
1245+
const prismaErrorServiceMock = {
1246+
handleError: jest.fn((error: any) => ({
1247+
message: error.message,
1248+
code: error.response?.code ?? 'UNKNOWN',
1249+
details: error.response?.details,
1250+
})),
1251+
} as unknown as any;
1252+
1253+
const resourceApiServiceMock = {
1254+
getMemberResourcesRoles: jest.fn(),
1255+
} as unknown as any;
1256+
1257+
const challengeApiServiceMock = {} as unknown as any;
1258+
1259+
const service = new ReviewService(
1260+
prismaMock,
1261+
prismaErrorServiceMock,
1262+
resourceApiServiceMock,
1263+
challengeApiServiceMock,
1264+
);
1265+
1266+
const reviewerUser: JwtUser = {
1267+
userId: 'member-100',
1268+
roles: [UserRole.Reviewer],
1269+
isMachine: false,
1270+
};
1271+
1272+
const baseReviewItem = {
1273+
id: 'item-1',
1274+
reviewId: 'review-1',
1275+
review: {
1276+
id: 'review-1',
1277+
resourceId: 'resource-1',
1278+
submission: {
1279+
challengeId: 'challenge-1',
1280+
},
1281+
},
1282+
} as any;
1283+
1284+
beforeEach(() => {
1285+
jest.resetAllMocks();
1286+
1287+
prismaMock.reviewItem.findUnique.mockResolvedValue(baseReviewItem);
1288+
prismaMock.reviewItem.delete.mockResolvedValue(undefined);
1289+
1290+
jest
1291+
.spyOn(service as any, 'recomputeAndUpdateReviewScores')
1292+
.mockResolvedValue(undefined);
1293+
});
1294+
1295+
afterEach(() => {
1296+
jest.restoreAllMocks();
1297+
});
1298+
1299+
it('blocks reviewers from deleting review items they do not own', async () => {
1300+
resourceApiServiceMock.getMemberResourcesRoles.mockResolvedValue([
1301+
{
1302+
id: 'resource-1',
1303+
memberId: 'someone-else',
1304+
challengeId: 'challenge-1',
1305+
memberHandle: 'otherHandle',
1306+
roleId: 'role-reviewer',
1307+
createdBy: 'system',
1308+
created: new Date().toISOString(),
1309+
roleName: 'Reviewer',
1310+
},
1311+
]);
1312+
1313+
await expect(
1314+
service.deleteReviewItem(reviewerUser, 'item-1'),
1315+
).rejects.toMatchObject({
1316+
status: 403,
1317+
response: expect.objectContaining({
1318+
code: 'REVIEW_ITEM_DELETE_FORBIDDEN_NOT_OWNER',
1319+
}),
1320+
});
1321+
1322+
expect(prismaMock.reviewItem.delete).not.toHaveBeenCalled();
1323+
expect(resourceApiServiceMock.getMemberResourcesRoles).toHaveBeenCalledWith(
1324+
'challenge-1',
1325+
'member-100',
1326+
);
1327+
});
1328+
1329+
it('allows reviewers to delete review items associated with their own review', async () => {
1330+
resourceApiServiceMock.getMemberResourcesRoles.mockResolvedValue([
1331+
{
1332+
id: 'resource-1',
1333+
memberId: 'member-100',
1334+
challengeId: 'challenge-1',
1335+
memberHandle: 'reviewerHandle',
1336+
roleId: 'role-reviewer',
1337+
createdBy: 'system',
1338+
created: new Date().toISOString(),
1339+
roleName: 'Reviewer',
1340+
},
1341+
]);
1342+
1343+
await expect(
1344+
service.deleteReviewItem(reviewerUser, 'item-1'),
1345+
).resolves.toMatchObject({
1346+
message: 'Review item item-1 deleted successfully.',
1347+
});
1348+
1349+
expect(prismaMock.reviewItem.delete).toHaveBeenCalledWith({
1350+
where: { id: 'item-1' },
1351+
});
1352+
});
1353+
});

src/api/review/review.service.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export class ReviewService {
277277
});
278278
}
279279

280-
const requesterMemberId = String(requester.userId ?? '');
280+
const requesterMemberId = String(requester.userId ?? '').trim();
281281

282282
if (!requesterMemberId) {
283283
throw new ForbiddenException({
@@ -321,9 +321,15 @@ export class ReviewService {
321321
let ownsReview = false;
322322

323323
if (hasReviewerRole) {
324-
ownsReview = requesterResources?.some(
325-
(resource) => resource.id === review.resourceId,
326-
);
324+
const normalizedReviewResourceId = String(review.resourceId ?? '').trim();
325+
ownsReview = requesterResources?.some((resource) => {
326+
const resourceId = String(resource.id ?? '').trim();
327+
const resourceMemberId = String(resource.memberId ?? '').trim();
328+
return (
329+
resourceId === normalizedReviewResourceId &&
330+
resourceMemberId === requesterMemberId
331+
);
332+
});
327333

328334
if (!ownsReview) {
329335
throw new ForbiddenException({
@@ -806,6 +812,7 @@ export class ReviewService {
806812

807813
const prismaBody = mapReviewRequestToDto(body) as any;
808814
prismaBody.phaseId = reviewPhaseId;
815+
prismaBody.resourceId = reviewerResource.id;
809816
const createdReview = await this.prisma.review.create({
810817
data: prismaBody,
811818
include: {

0 commit comments

Comments
 (0)