diff --git a/app/features/tournament-admin/actions/to.$id.admin.seeds.server.test.ts b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.test.ts new file mode 100644 index 000000000..47f1253b5 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; +import { wrappedAction } from "~/utils/Test"; +import type { adminSeedsActionSchema } from "../tournament-admin-schemas"; +import { action } from "./to.$id.admin.seeds.server"; + +const seedsAction = wrappedAction({ action }); + +const teamRow = (tournamentTeamId: number) => + db + .selectFrom("TournamentTeam") + .select(["id", "tournamentId", "seed", "startingBracketIdx"]) + .where("id", "=", tournamentTeamId) + .executeTakeFirstOrThrow(); + +describe("tournament seeds admin action", () => { + test("does not touch the seeds of a team of another tournament", async () => { + await UserFactory.createAdmin(); + const attacker = await UserFactory.createRegular(); + const victimTo = await UserFactory.create(); + const [player] = await UserFactory.createMany(1); + + // the attacker organizes a tournament of their own + const ownTournament = await TournamentFactory.create({ + authorId: attacker.id, + }); + + // somebody else's tournament, with a team they seeded themselves + const otherTournament = await TournamentFactory.create({ + authorId: victimTo.id, + }); + const otherTeam = await TournamentTeamFactory.create({ + tournamentId: otherTournament.id, + memberUserIds: [player.id], + }); + + await seedsAction( + { _action: "UPDATE_SEEDS", seeds: JSON.stringify([otherTeam.id]) as any }, + { user: "regular", params: { id: String(ownTournament.id) } }, + ); + + const after = await teamRow(otherTeam.id); + expect(after.tournamentId).toBe(otherTournament.id); + expect(after.seed, "another tournament's team got seeded").toBeNull(); + }); + + test("does not touch the starting bracket of a team of another tournament", async () => { + await UserFactory.createAdmin(); + const attacker = await UserFactory.createRegular(); + const victimTo = await UserFactory.create(); + const [player] = await UserFactory.createMany(1); + + const ownTournament = await TournamentFactory.create({ + authorId: attacker.id, + }); + + const otherTournament = await TournamentFactory.create({ + authorId: victimTo.id, + }); + const otherTeam = await TournamentTeamFactory.create({ + tournamentId: otherTournament.id, + memberUserIds: [player.id], + }); + + await seedsAction( + { + _action: "UPDATE_STARTING_BRACKETS", + startingBrackets: JSON.stringify([ + { tournamentTeamId: otherTeam.id, startingBracketIdx: 0 }, + ]) as any, + }, + { user: "regular", params: { id: String(ownTournament.id) } }, + ); + + const after = await teamRow(otherTeam.id); + expect( + after.startingBracketIdx, + "another tournament's team got moved to a starting bracket", + ).toBeNull(); + }); +}); diff --git a/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts index ae9c30765..edba548bd 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts @@ -1,6 +1,7 @@ import type { ActionFunction } from "react-router"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { clearTournamentDataCache, requireTournamentOrganizer, @@ -30,6 +31,7 @@ export const action: ActionFunction = async ({ request, params }) => { case "UPDATE_SEEDS": { requireTournamentOrganizer(tournament, user); errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); + validateTeamsOfTournament(tournament, data.seeds); await TournamentRepository.updateTeamSeeds({ tournamentId, @@ -54,6 +56,10 @@ export const action: ActionFunction = async ({ request, params }) => { ), "Invalid starting bracket idx", ); + validateTeamsOfTournament( + tournament, + data.startingBrackets.map((t) => t.tournamentTeamId), + ); await TournamentTeamRepository.updateStartingBrackets( data.startingBrackets, @@ -73,10 +79,9 @@ export const action: ActionFunction = async ({ request, params }) => { "No starting bracket has A/B divisions enabled", ); - const validTeamIds = new Set(tournament.ctx.teams.map((t) => t.id)); - errorToastIfFalsy( - data.abDivisions.every((t) => validTeamIds.has(t.tournamentTeamId)), - "Invalid tournament team id", + validateTeamsOfTournament( + tournament, + data.abDivisions.map((t) => t.tournamentTeamId), ); await TournamentTeamRepository.updateAbDivisions(data.abDivisions); @@ -93,3 +98,15 @@ export const action: ActionFunction = async ({ request, params }) => { return successToast(message); }; + +function validateTeamsOfTournament( + tournament: Tournament, + tournamentTeamIds: number[], +) { + const validTeamIds = new Set(tournament.ctx.teams.map((t) => t.id)); + + errorToastIfFalsy( + tournamentTeamIds.every((id) => validTeamIds.has(id)), + "Invalid tournament team id", + ); +}