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 }) => {
// 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.