From c9b63d45cffa78831b60bf7a719ee63f080e85d6 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 15:22:06 -0400 Subject: [PATCH] bug: check-in enforces the match's check-in setting checkIntoMatch only checked the match status, so in Admin mode a player could still check in by calling the action directly (and enough of them pushed the match Live), and in Captains mode any lineup player could. It now evaluates can_check_in for the caller's own session. --- src/matches/matches.controller.ts | 24 +++-- test/match-check-in.spec.ts | 166 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 test/match-check-in.spec.ts diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 159bf400..7687b29d 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -2580,19 +2580,25 @@ export class MatchesController { */ @HasuraAction() public async checkIntoMatch(data: { user: User; match_id: string }) { - const { matches_by_pk } = await this.hasura.query({ - matches_by_pk: { - __args: { - id: data.match_id, - }, - status: true, - }, - }); + const session = await this.hasura.getHasuraHeaders(data.user.steam_id); + + const [match] = await this.postgres.query< + Array<{ status: string; can_check_in: boolean }> + >( + `SELECT m.status, can_check_in(m, $2::json) AS can_check_in + FROM matches m + WHERE m.id = $1::uuid`, + [data.match_id, JSON.stringify(session)], + ); - if (matches_by_pk.status !== "WaitingForCheckIn") { + if (match?.status !== "WaitingForCheckIn") { throw Error("match is not accepting check in's at this time"); } + if (!match.can_check_in) { + throw Error("you are not allowed to check in to this match"); + } + // Checking in is the commitment to play, so it is the right gate: letting // it through and only enforcing at Live means a player reaches the server // unwatched and the match pauses on them instead. diff --git a/test/match-check-in.spec.ts b/test/match-check-in.spec.ts new file mode 100644 index 00000000..31c9daa1 --- /dev/null +++ b/test/match-check-in.spec.ts @@ -0,0 +1,166 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { MatchesController } from "./../src/matches/matches.controller"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// The check-in button is gated by can_check_in, but the action is callable +// directly, so it has to apply the same rule for the caller's own session. +describe("checkIntoMatch enforces the match's check-in setting (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("MatchCheckInTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199950000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + }); + + const waitingMatch = async (checkInSetting: string) => { + const match = await fx.match({ type: "Wingman", mr: 8 }); + await postgres.query( + "UPDATE match_options SET check_in_setting = $2 WHERE id = $1", + [match.options_id, checkInSetting], + ); + const first = await fx.lineupPlayer(match.lineup_1_id); + const second = await fx.lineupPlayer(match.lineup_1_id); + await postgres.query( + "UPDATE matches SET status = 'WaitingForCheckIn' WHERE id = $1", + [match.id], + ); + + const [{ steam_id: captain }] = await postgres.query< + Array<{ steam_id: string }> + >( + `SELECT steam_id::text FROM match_lineup_players + WHERE match_lineup_id = $1 AND captain`, + [match.lineup_1_id], + ); + + return { + id: match.id, + captain, + player: captain === first ? second : first, + }; + }; + + const checkIn = async (matchId: string, steamId: string) => { + const controller = Object.create(MatchesController.prototype); + + controller.postgres = postgres; + controller.camera = { isRequired: jest.fn().mockResolvedValue(false) }; + controller.hasura = { + getHasuraHeaders: jest.fn(async (id: string) => { + const [player] = await postgres.query>( + "SELECT role FROM players WHERE steam_id = $1", + [id], + ); + return { "x-hasura-role": player.role, "x-hasura-user-id": id }; + }), + query: jest.fn(async () => { + const [match] = await postgres.query>( + "SELECT status FROM matches WHERE id = $1", + [matchId], + ); + return { matches_by_pk: match }; + }), + mutation: jest.fn().mockResolvedValue({ + update_match_lineup_players: { affected_rows: 1 }, + update_matches: { affected_rows: 0 }, + }), + }; + + const result = controller.checkIntoMatch({ + match_id: matchId, + user: { steam_id: steamId, role: "user" }, + }); + + return { result, mutation: controller.hasura.mutation as jest.Mock }; + }; + + it("Admin: a player in the lineup cannot check themselves in", async () => { + const match = await waitingMatch("Admin"); + + const { result, mutation } = await checkIn(match.id, match.player); + + await expect(result).rejects.toThrow( + "you are not allowed to check in to this match", + ); + expect(mutation).not.toHaveBeenCalled(); + }); + + it("Admin: an administrator in the lineup can check in", async () => { + const match = await waitingMatch("Admin"); + await postgres.query( + "UPDATE players SET role = 'administrator' WHERE steam_id = $1", + [match.player], + ); + + const { result } = await checkIn(match.id, match.player); + + await expect(result).resolves.toEqual({ success: true }); + }); + + it("Captains: a player who is not captain cannot check in", async () => { + const match = await waitingMatch("Captains"); + + const { result, mutation } = await checkIn(match.id, match.player); + + await expect(result).rejects.toThrow( + "you are not allowed to check in to this match", + ); + expect(mutation).not.toHaveBeenCalled(); + }); + + it("Captains: the captain can check in", async () => { + const match = await waitingMatch("Captains"); + + const { result } = await checkIn(match.id, match.captain); + + await expect(result).resolves.toEqual({ success: true }); + }); + + it("Players: anyone in a lineup can check in, nobody outside it", async () => { + const match = await waitingMatch("Players"); + + await expect( + (await checkIn(match.id, match.player)).result, + ).resolves.toEqual({ success: true }); + + const outsider = await checkIn(match.id, await fx.player()); + await expect(outsider.result).rejects.toThrow( + "you are not allowed to check in to this match", + ); + expect(outsider.mutation).not.toHaveBeenCalled(); + }); + + it("still refuses a match that is not waiting for check-in", async () => { + const match = await waitingMatch("Players"); + await postgres.query( + "UPDATE matches SET status = 'PickingPlayers' WHERE id = $1", + [match.id], + ); + + const { result, mutation } = await checkIn(match.id, match.player); + + await expect(result).rejects.toThrow( + "match is not accepting check in's at this time", + ); + expect(mutation).not.toHaveBeenCalled(); + }); +});