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:
Matt
2026-06-04 15:49:13 +02:00
parent 8ee517f6ca
commit 34bb2bad57
3 changed files with 108 additions and 29 deletions

View File

@@ -1272,11 +1272,23 @@ export const finalistRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .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({ const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId }, 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 cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24 const windowHours = cfg.confirmationWindowHours ?? 24
@@ -1308,17 +1320,12 @@ export const finalistRouter = router({
const projectMap = new Map(projects.map((p) => [p.id, p])) const projectMap = new Map(projects.map((p) => [p.id, p]))
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
let enrolled = 0 // Pre-validate every ADMIN_CONFIRM enrollment up front so a bad entry in
let emailed = 0 // a multi-team batch fails before any project is partially written.
let adminConfirmed = 0
const skipped: Array<{ projectId: string; reason: string }> = []
for (const enrollment of input.enrollments) { for (const enrollment of input.enrollments) {
if (enrollment.mode !== 'ADMIN_CONFIRM') continue
const project = projectMap.get(enrollment.projectId)! const project = projectMap.get(enrollment.projectId)!
const cap = project.program.defaultAttendeeCap const cap = project.program.defaultAttendeeCap
// ADMIN_CONFIRM pre-validation: validate attendingUserIds before touching DB
if (enrollment.mode === 'ADMIN_CONFIRM') {
const attendingUserIds = enrollment.attendingUserIds ?? [] const attendingUserIds = enrollment.attendingUserIds ?? []
if (attendingUserIds.length === 0) { if (attendingUserIds.length === 0) {
throw new TRPCError({ 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) // Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
await ctx.prisma.projectRoundState.createMany({ await ctx.prisma.projectRoundState.createMany({
data: [{ projectId: enrollment.projectId, roundId: input.roundId }], data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
@@ -1438,6 +1453,24 @@ export const finalistRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .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 // Step 1: Delete the FinalistConfirmation (cascade removes AttendingMember
// / FlightDetail / VisaApplication / MemberLunchPick). // / FlightDetail / VisaApplication / MemberLunchPick).
// deleteMany is no-op-safe when no row exists. // deleteMany is no-op-safe when no row exists.

View File

@@ -316,6 +316,31 @@ describe('finalist.enrollFinalists', () => {
}), }),
).rejects.toThrow(/cap/i) ).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 ──────────────────────────────────── // ─── finalist.listEnrollmentCandidates ────────────────────────────────────

View File

@@ -204,4 +204,25 @@ describe('finalist.unenroll', () => {
}) })
expect(prsAfter).toBeNull() 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)
})
}) })