From 87311df568a3ecfc7bce8bea5a2a3ff3964608f1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 13 Feb 2026 16:21:46 +1100 Subject: [PATCH 1/2] Remove assignments from engagements list request response (https://topcoder.atlassian.net/browse/PM-3835) --- src/engagements/engagements.service.spec.ts | 121 ++++++++++++++++++++ src/engagements/engagements.service.ts | 46 +++++--- 2 files changed, 149 insertions(+), 18 deletions(-) diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index 0f62af7..19477ee 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -9,6 +9,8 @@ describe("EngagementsService", () => { create: jest.Mock; findUnique: jest.Mock; update: jest.Mock; + findMany: jest.Mock; + count: jest.Mock; }; }; let projectService: { validateProjectExists: jest.Mock }; @@ -43,6 +45,8 @@ describe("EngagementsService", () => { create: jest.fn(), findUnique: jest.fn(), update: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), }, }; projectService = { @@ -133,6 +137,123 @@ describe("EngagementsService", () => { ); }); + it("does not include assignment details for public engagement listings", async () => { + db.engagement.findMany.mockResolvedValue([ + { + id: "eng-1", + projectId: "project-1", + title: "Public engagement", + description: "Public description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: "IMMEDIATE", + status: "OPEN", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + updatedAt: new Date("2026-02-11T10:00:00.000Z"), + createdBy: "123456", + isPrivate: false, + requiredMemberCount: 2, + _count: { + applications: 3, + }, + }, + ]); + db.engagement.count.mockResolvedValue(1); + + const result = await service.findAll({ + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any); + + expect(db.engagement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: { + _count: { + select: { + applications: true, + }, + }, + }, + }), + ); + + expect(result.data[0]).not.toHaveProperty("assignments"); + expect(result.data[0]).not.toHaveProperty("assignedMemberId"); + expect(result.data[0]).not.toHaveProperty("assignedMemberHandle"); + expect(result.data[0]).not.toHaveProperty("assignedMembers"); + expect(result.data[0]).not.toHaveProperty("assignedMemberHandles"); + }); + + it("includes assignment details for privileged engagement listings", async () => { + db.engagement.findMany.mockResolvedValue([ + { + id: "eng-1", + projectId: "project-1", + title: "Private engagement", + description: "Private description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: "IMMEDIATE", + status: "OPEN", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + updatedAt: new Date("2026-02-11T10:00:00.000Z"), + createdBy: "123456", + isPrivate: true, + assignments: [ + { + id: "assignment-1", + engagementId: "eng-1", + memberId: "100000", + memberHandle: "member1", + status: AssignmentStatus.SELECTED, + createdAt: new Date("2026-02-11T11:00:00.000Z"), + updatedAt: new Date("2026-02-11T11:00:00.000Z"), + agreementRate: "80", + otherRemarks: "Confidential terms", + startDate: new Date("2026-02-12T00:00:00.000Z"), + endDate: new Date("2026-03-12T00:00:00.000Z"), + terminationReason: null, + }, + ], + _count: { + applications: 1, + }, + }, + ]); + db.engagement.count.mockResolvedValue(1); + + const result = await service.findAll({ + includePrivate: true, + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any); + + expect(db.engagement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: { + _count: { + select: { + applications: true, + }, + }, + assignments: true, + }, + }), + ); + + expect(result.data[0]).toHaveProperty("assignments"); + expect(result.data[0]).toHaveProperty("assignedMemberId", "100000"); + expect(result.data[0]).toHaveProperty("assignedMemberHandle", "member1"); + expect(result.data[0]).toHaveProperty("assignedMembers", ["100000"]); + expect(result.data[0]).toHaveProperty("assignedMemberHandles", ["member1"]); + }); + it("sets assignment endDate to now when status is terminated", async () => { const now = new Date("2026-02-11T12:00:00.000Z"); jest.useFakeTimers().setSystemTime(now); diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index 91fcb31..91426b5 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -389,6 +389,7 @@ export class EngagementsService { const orderBy: Prisma.EngagementOrderByWithRelationInput = { [sortBy]: query.sortOrder, }; + const includeAssignments = query.includePrivate === true; const [data, totalCount] = await Promise.all([ this.db.engagement.findMany({ @@ -396,25 +397,40 @@ export class EngagementsService { skip, take: perPage, orderBy, - include: { - _count: { - select: { - applications: true, + include: includeAssignments + ? { + _count: { + select: { + applications: true, + }, + }, + assignments: true, + } + : { + _count: { + select: { + applications: true, + }, + }, }, - }, - assignments: true, - }, }), this.db.engagement.count({ where }), ]); const totalPages = totalCount ? Math.ceil(totalCount / perPage) : 0; - const engagements = data.map(({ _count, ...engagement }) => - this.applyAssignmentFields({ + const engagements = data.map(({ _count, ...engagement }) => { + const engagementWithCount = { ...engagement, applicationsCount: _count.applications, - }), - ); + } as Engagement & { + assignments?: EngagementAssignment[]; + applicationsCount: number; + }; + + return includeAssignments + ? this.applyAssignmentFields(engagementWithCount) + : engagementWithCount; + }); const hydratedEngagements = await this.hydrateCreatorEmails(engagements); return { @@ -1163,14 +1179,8 @@ export class EngagementsService { status: EngagementStatus.OPEN, }, orderBy: { createdAt: "desc" }, - include: { assignments: true }, }); - - const engagementsWithFields = engagements.map((engagement) => - this.applyAssignmentFields(engagement), - ); - - return this.hydrateCreatorEmails(engagementsWithFields); + return this.hydrateCreatorEmails(engagements); } private normalizeAssignmentOfferDetails(details?: AssignmentDetailsDto): { From d3ab13c76e0d0e7ead06350a282910c6bf6fb8b4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 13 Feb 2026 16:27:31 +1100 Subject: [PATCH 2/2] Additional guard on isPrivate=true to only admin, TM, and M2M token --- src/engagements/engagements.controller.ts | 36 +++++++++++++- test/auth.e2e-spec.ts | 58 +++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index efb001c..cba97ae 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -12,6 +12,7 @@ import { Put, Query, Req, + UnauthorizedException, UseGuards, } from "@nestjs/common"; import { @@ -30,7 +31,12 @@ import { import { Request } from "express"; import { PermissionsGuard } from "../auth/guards/permissions.guard"; import { Scopes as ScopesDecorator } from "../auth/decorators/scopes.decorator"; -import { Scopes as AppScopes, PrivilegedUserRoles } from "../app-constants"; +import { + Scopes as AppScopes, + PrivilegedUserRoles, + TalentManagerRoles, + UserRoles, +} from "../app-constants"; import { CreateEngagementDto, CreateEngagementDurationDatesDto, @@ -58,6 +64,9 @@ export class EngagementsController { private readonly privilegedRoles = new Set( PrivilegedUserRoles.map((role) => role.toLowerCase()), ); + private readonly includePrivateRoles = new Set( + [UserRoles.Admin, ...TalentManagerRoles].map((role) => role.toLowerCase()), + ); constructor(private readonly engagementsService: EngagementsService) {} @@ -127,7 +136,7 @@ export class EngagementsController { @Req() req: Request & { authUser?: Record }, ): Promise> { if (query.includePrivate) { - this.assertAdminOrPm(req.authUser); + this.assertCanIncludePrivate(req.authUser); } return this.engagementsService.findAll(query); } @@ -426,4 +435,27 @@ export class EngagementsController { ); } } + + private assertCanIncludePrivate(authUser?: Record) { + if (!authUser) { + throw new UnauthorizedException( + "Authentication is required to include private engagements.", + ); + } + + if (authUser.isMachine) { + return; + } + + const roles = getUserRoles(authUser); + const isAllowed = roles.some((role) => + this.includePrivateRoles.has(role?.toLowerCase()), + ); + + if (!isAllowed) { + throw new UnauthorizedException( + "You are not authorized to include private engagements.", + ); + } + } } diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index 4299c95..d25f8eb 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -28,6 +28,21 @@ const tokenFixtures: Record> = { userId: "123456", roles: [UserRoles.Admin], }, + "talent-manager-user": { + isMachine: false, + userId: "222222", + roles: [UserRoles.TalentManager], + }, + "project-manager-user": { + isMachine: false, + userId: "333333", + roles: [UserRoles.ProjectManager], + }, + "task-manager-user": { + isMachine: false, + userId: "444444", + roles: [UserRoles.TaskManager], + }, "member-user": { isMachine: false, userId: "654321", @@ -265,6 +280,49 @@ describe("Authentication & Authorization (e2e)", () => { }); }); + describe("includePrivate Access", () => { + it("allows admin user to request includePrivate=true", async () => { + await request(app.getHttpServer()) + .get("/engagements?includePrivate=true") + .set("Authorization", "Bearer admin-user") + .expect(200); + }); + + it("allows talent manager user to request includePrivate=true", async () => { + await request(app.getHttpServer()) + .get("/engagements?includePrivate=true") + .set("Authorization", "Bearer talent-manager-user") + .expect(200); + }); + + it("allows M2M token to request includePrivate=true", async () => { + await request(app.getHttpServer()) + .get("/engagements?includePrivate=true") + .set("Authorization", "Bearer m2m-read") + .expect(200); + }); + + it("returns 401 for project manager requesting includePrivate=true", async () => { + await request(app.getHttpServer()) + .get("/engagements?includePrivate=true") + .set("Authorization", "Bearer project-manager-user") + .expect(401); + }); + + it("returns 401 for task manager requesting includePrivate=true", async () => { + await request(app.getHttpServer()) + .get("/engagements?includePrivate=true") + .set("Authorization", "Bearer task-manager-user") + .expect(401); + }); + + it("returns 401 for anonymous request with includePrivate=true", async () => { + await request(app.getHttpServer()) + .get("/engagements?includePrivate=true") + .expect(401); + }); + }); + describe("Role-Based Access", () => { it("returns 401 when token is missing", async () => { await request(app.getHttpServer())