diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 433d7dc..f123ea6 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -1272,11 +1272,23 @@ export const finalistRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - // Resolve the LIVE_FINAL round + confirmationWindowHours + // Resolve the LIVE_FINAL round + confirmationWindowHours. Validate the + // round belongs to the target program so an admin can't inject projects + // into another edition's round. const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, - select: { id: true, configJson: true }, + select: { + id: true, + configJson: true, + competition: { select: { programId: true } }, + }, }) + if (round.competition.programId !== input.programId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Round does not belong to this program', + }) + } const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number } const windowHours = cfg.confirmationWindowHours ?? 24 @@ -1308,6 +1320,36 @@ export const finalistRouter = router({ const projectMap = new Map(projects.map((p) => [p.id, p])) const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') + // Pre-validate every ADMIN_CONFIRM enrollment up front so a bad entry in + // a multi-team batch fails before any project is partially written. + for (const enrollment of input.enrollments) { + if (enrollment.mode !== 'ADMIN_CONFIRM') continue + const project = projectMap.get(enrollment.projectId)! + const cap = project.program.defaultAttendeeCap + const attendingUserIds = enrollment.attendingUserIds ?? [] + if (attendingUserIds.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`, + }) + } + if (attendingUserIds.length > cap) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`, + }) + } + const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId)) + for (const uid of attendingUserIds) { + if (!teamUserIds.has(uid)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `User ${uid} is not a team member of project ${project.id}`, + }) + } + } + } + let enrolled = 0 let emailed = 0 let adminConfirmed = 0 @@ -1315,33 +1357,6 @@ export const finalistRouter = router({ for (const enrollment of input.enrollments) { const project = projectMap.get(enrollment.projectId)! - const cap = project.program.defaultAttendeeCap - - // ADMIN_CONFIRM pre-validation: validate attendingUserIds before touching DB - if (enrollment.mode === 'ADMIN_CONFIRM') { - const attendingUserIds = enrollment.attendingUserIds ?? [] - if (attendingUserIds.length === 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`, - }) - } - if (attendingUserIds.length > cap) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`, - }) - } - const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId)) - for (const uid of attendingUserIds) { - if (!teamUserIds.has(uid)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `User ${uid} is not a team member of project ${project.id}`, - }) - } - } - } // Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent) await ctx.prisma.projectRoundState.createMany({ @@ -1438,6 +1453,24 @@ export const finalistRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + // Guard: the project and round must belong to the same program, so a + // mismatched (projectId, roundId) pair from different editions can't be + // used to delete the wrong project's confirmation or round membership. + const project = await ctx.prisma.project.findUniqueOrThrow({ + where: { id: input.projectId }, + select: { programId: true }, + }) + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { competition: { select: { programId: true } } }, + }) + if (project.programId !== round.competition.programId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Project and round belong to different programs', + }) + } + // Step 1: Delete the FinalistConfirmation (cascade removes AttendingMember // / FlightDetail / VisaApplication / MemberLunchPick). // deleteMany is no-op-safe when no row exists. diff --git a/tests/unit/finalist-enrollment.test.ts b/tests/unit/finalist-enrollment.test.ts index 1d18fb8..6cc16df 100644 --- a/tests/unit/finalist-enrollment.test.ts +++ b/tests/unit/finalist-enrollment.test.ts @@ -316,6 +316,31 @@ describe('finalist.enrollFinalists', () => { }), ).rejects.toThrow(/cap/i) }) + + it('rejects a roundId that belongs to a different program', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const a = await setupEnrollFixture(`enroll-xprogram-a-${uid()}`) + const b = await setupEnrollFixture(`enroll-xprogram-b-${uid()}`) + programIds.push(a.program.id, b.program.id) + userIds.push(a.lead.id, a.member.id, b.lead.id, b.member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // Program A's project + Program B's LIVE_FINAL round → must be rejected. + await expect( + caller.enrollFinalists({ + programId: a.program.id, + roundId: b.liveFinalRound.id, + enrollments: [{ projectId: a.project.id, mode: 'EMAIL' }], + }), + ).rejects.toThrow(/does not belong to this program/i) + }) }) // ─── finalist.listEnrollmentCandidates ──────────────────────────────────── diff --git a/tests/unit/finalist-unenroll.test.ts b/tests/unit/finalist-unenroll.test.ts index 020bfb3..e4ba4b6 100644 --- a/tests/unit/finalist-unenroll.test.ts +++ b/tests/unit/finalist-unenroll.test.ts @@ -204,4 +204,25 @@ describe('finalist.unenroll', () => { }) expect(prsAfter).toBeNull() }) + + it('rejects a project/round pair from different programs', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const a = await setupUnenrollFixture(`unenroll-xprogram-a-${uid()}`) + const b = await setupUnenrollFixture(`unenroll-xprogram-b-${uid()}`) + programIds.push(a.program.id, b.program.id) + userIds.push(a.lead.id, a.member.id, b.lead.id, b.member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // Program A's project + Program B's round → rejected before any delete. + await expect( + caller.unenroll({ projectId: a.project.id, roundId: b.liveFinalRound.id }), + ).rejects.toThrow(/different programs/i) + }) })