fix(finalist): program-scope guards on enroll/unenroll (code review)
- enrollFinalists: reject a roundId whose competition belongs to a different program than input.programId. - unenroll: reject a project/round pair from different programs before any delete. - Hoist ADMIN_CONFIRM attendee validation to a pre-pass so a bad entry in a multi-team batch fails before any project is partially written. - Add regression tests for both cross-program guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,17 +1320,12 @@ export const finalistRouter = router({
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
|
||||
|
||||
let enrolled = 0
|
||||
let emailed = 0
|
||||
let adminConfirmed = 0
|
||||
const skipped: Array<{ projectId: string; reason: string }> = []
|
||||
|
||||
// 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
|
||||
|
||||
// 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({
|
||||
@@ -1343,6 +1350,14 @@ export const finalistRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
let enrolled = 0
|
||||
let emailed = 0
|
||||
let adminConfirmed = 0
|
||||
const skipped: Array<{ projectId: string; reason: string }> = []
|
||||
|
||||
for (const enrollment of input.enrollments) {
|
||||
const project = projectMap.get(enrollment.projectId)!
|
||||
|
||||
// Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
|
||||
await ctx.prisma.projectRoundState.createMany({
|
||||
data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user