diff --git a/app/db/reset.ts b/app/db/reset.ts index e85460a0c..3646a03fa 100644 --- a/app/db/reset.ts +++ b/app/db/reset.ts @@ -1,6 +1,7 @@ import { resetFactories } from "~/db/seed/core/defineFactory"; import { deleteAllRows } from "~/db/wipe"; import { markDatabaseClean } from "~/db/write-tracker"; +import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; /** * Deletes all rows except migration bookkeeping. `app/test-setup.ts` runs it after every writing @@ -10,6 +11,7 @@ export const dbReset = async () => { await deleteAllRows(); resetFactories(); + clearAllTournamentDataCache(); // last, because the deletes above are themselves writes markDatabaseClean(); }; diff --git a/app/features/api-public/api-auth-middleware.server.ts b/app/features/api-public/api-auth-middleware.server.ts index 76b1ed9a9..1b70535a2 100644 --- a/app/features/api-public/api-auth-middleware.server.ts +++ b/app/features/api-public/api-auth-middleware.server.ts @@ -52,13 +52,10 @@ export const apiAuthMiddleware: MiddlewareFn = async ({ request }, next) => { return Response.json({ error: "Write token required" }, { status: 403 }); } - if (request.method === "POST") { - const user = await UserRepository.findLeanById(tokenInfo.userId); - if (!user) { - return Response.json({ error: "User not found" }, { status: 401 }); - } - return userAsyncLocalStorage.run({ user }, () => next()); + const user = await UserRepository.findLeanById(tokenInfo.userId); + if (!user) { + return Response.json({ error: "User not found" }, { status: 401 }); } - return next(); + return userAsyncLocalStorage.run({ user }, () => next()); }; diff --git a/app/features/api-public/routes/tournament.$id.teams.test.ts b/app/features/api-public/routes/tournament.$id.teams.test.ts index 93a2b403a..ed91d0b8f 100644 --- a/app/features/api-public/routes/tournament.$id.teams.test.ts +++ b/app/features/api-public/routes/tournament.$id.teams.test.ts @@ -2,19 +2,25 @@ import { beforeEach, 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 { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; -import { withUserId, wrappedLoader } from "~/utils/Test"; +import { type TestUser, withUserId, wrappedLoader } from "~/utils/Test"; import type { GetTournamentTeamsResponse } from "../schema"; import { loader } from "./tournament.$id.teams"; const TEAM_NAME = "Team Olive"; const users = UserFactory.pool(); +const organizerId = () => users.id(1); +const captainId = () => users.id(2); +const outsiderId = () => users.id(3); const teamsLoader = wrappedLoader({ loader }); -const fetchTeams = async (tournamentId: number) => { +/** Fetches as the owner of the API token, or anonymously when no user is given. */ +const fetchTeams = async (tournamentId: number, user?: TestUser) => { const response = await teamsLoader({ + user, params: { id: String(tournamentId) }, }); @@ -75,6 +81,25 @@ const tournamentWithWalkovers = async () => { return { tournament }; }; +/** A team of the captain with a pickup logo and a map pool, both hidden before the start. */ +const teamWithHiddenInfo = async (tournamentArgs?: { isDraft?: boolean }) => { + const tournament = await TournamentFactory.create({ + authorId: organizerId(), + ...tournamentArgs, + }); + await TournamentTeamFactory.create( + { + tournamentId: tournament.id, + memberUserIds: [captainId()], + hasAvatar: true, + mapPool: new MapPool({ TW: [], SZ: [1, 2], TC: [3, 4], RM: [], CB: [] }), + }, + { isCheckedIn: true }, + ); + + return { tournament }; +}; + /** Four one-player teams through a single elimination bracket, the higher seed winning every map. */ const playedTournament = () => TournamentFactory.createPlayed( @@ -87,6 +112,54 @@ describe("GET /api/tournament/:id/teams", () => { await users.create(4); }); + test("responds 404 for a draft tournament to anyone but its organizers", async () => { + const { tournament } = await teamWithHiddenInfo({ isDraft: true }); + + await expect(fetchTeams(tournament.id, outsiderId())).rejects.toThrow( + "404", + ); + expect(await fetchTeams(tournament.id, organizerId())).toHaveLength(1); + }); + + test("shows friend codes only to the organizers", async () => { + const { tournament } = await teamWithHiddenInfo(); + + const asOrganizer = await fetchTeams(tournament.id, organizerId()); + const asOutsider = await fetchTeams(tournament.id, outsiderId()); + + expect(asOrganizer[0].members[0].friendCode).toEqual(expect.any(String)); + expect(asOutsider[0].members[0].friendCode).toBeNull(); + }); + + test("hides map pools and pickup logos before the start from everyone but organizers and the team itself", async () => { + const { tournament } = await teamWithHiddenInfo(); + + const asOrganizer = await fetchTeams(tournament.id, organizerId()); + const asCaptain = await fetchTeams(tournament.id, captainId()); + const asOutsider = await fetchTeams(tournament.id, outsiderId()); + + expect(asOrganizer[0].mapPool).toHaveLength(4); + expect(asOrganizer[0].logoUrl).toEqual(expect.any(String)); + expect(asCaptain[0].mapPool).toHaveLength(4); + expect(asCaptain[0].logoUrl).toEqual(expect.any(String)); + expect(asOutsider[0].mapPool).toBeNull(); + expect(asOutsider[0].logoUrl).toBeNull(); + }); + + test("reveals map pools and pickup logos to everyone once the tournament has started", async () => { + const { tournament } = await teamWithHiddenInfo(); + await TournamentTeamFactory.create( + { tournamentId: tournament.id, memberUserIds: [users.id(4)] }, + { isCheckedIn: true }, + ); + await TournamentFactory.startBracket(tournament.id); + + const asOutsider = await fetchTeams(tournament.id, outsiderId()); + + expect(asOutsider[0].mapPool).toHaveLength(4); + expect(asOutsider[0].logoUrl).toEqual(expect.any(String)); + }); + test("returns the tournament name organizers gave a player instead of their username", async () => { const { organizer, player, tournament, team } = await registeredPlayer(); diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index 629f1c1dc..ff1081310 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -3,6 +3,7 @@ import type { LoaderFunctionArgs } from "react-router"; import * as v from "valibot"; import { db } from "~/db/sql"; import type { TournamentSettings } from "~/db/tables-json"; +import { getUser } from "~/features/auth/core/user.server"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; import * as Standings from "~/features/tournament/core/Standings"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -11,7 +12,13 @@ import { sortTeamsBySeeding, } from "~/features/tournament/tournament-utils"; import * as Progression from "~/features/tournament-bracket/core/Progression"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { + canSeeTournamentFriendCodes, + isTournamentTeamInfoRevealed, + requireTournamentVisible, + tournamentDataCached, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -38,23 +45,16 @@ const ZERO_STATS: Standings.TeamRecord = { export const loader = async ({ params }: LoaderFunctionArgs) => { const t = await getFixedTForLanguage("en", ["game-misc"]); + const user = getUser(); const { id: tournamentId } = parseParams({ params, schema: paramsSchema, }); - const tournament = await db - .selectFrom("Tournament") - .select(({ exists, selectFrom }) => [ - "Tournament.settings", - exists( - selectFrom("TournamentStage") - .select("TournamentStage.id") - .where("TournamentStage.tournamentId", "=", tournamentId), - ).as("hasStarted"), - ]) - .where("Tournament.id", "=", tournamentId) - .executeTakeFirst(); + const tournament = await tournamentDataCached(tournamentId); + requireTournamentVisible({ ctx: tournament.ctx, user }); + const hasStarted = tournament.data.stage.length > 0; + const revealInfo = isTournamentTeamInfoRevealed({ tournament, user }); const teams = await db .selectFrom("TournamentTeam") @@ -142,15 +142,18 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { .orderBy("TournamentTeam.createdAt", "asc") .execute(); - const friendCodes = - await TournamentRepository.findFriendCodesByTournamentId(tournamentId); + const friendCodes = canSeeTournamentFriendCodes({ + ctx: tournament.ctx, + user, + }) + ? await TournamentRepository.findFriendCodesByTournamentId(tournamentId) + : null; - const seedByTeamId = - tournament?.hasStarted && tournament.settings - ? seedsOfStartedTournament({ teams, settings: tournament.settings }) - : null; + const seedByTeamId = hasStarted + ? seedsOfStartedTournament({ teams, settings: tournament.ctx.settings }) + : null; - const fullTournament = tournament?.hasStarted + const fullTournament = hasStarted ? await tournamentFromDB(tournamentId) : null; const placementByTeamId = fullTournament @@ -165,6 +168,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { : null; const result: GetTournamentTeamsResponse = teams.map((team) => { + const isOwnTeam = team.members.some((member) => member.userId === user?.id); + const showTeamInfo = revealInfo || isOwnTeam; + const pickupAvatarUrl = showTeamInfo ? team.avatarUrl : null; + return { id: team.id, name: team.name, @@ -198,13 +205,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { captain: member.role === "OWNER", inGameName: member.inGameName, pronouns: member.pronouns, - friendCode: friendCodes[member.userId], + friendCode: friendCodes?.[member.userId] ?? null, joinedAt: databaseTimestampToDate(member.createdAt).toISOString(), }; }), - logoUrl: team.team?.logoUrl ?? team.avatarUrl, + logoUrl: team.team?.logoUrl ?? pickupAvatarUrl, mapPool: - team.mapPool.length > 0 + showTeamInfo && team.mapPool.length > 0 ? team.mapPool.map((map) => { return { mode: map.mode, diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 9611ee7ad..47d6466ed 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -146,7 +146,7 @@ export type GetTournamentTeamsResponse = Array<{ url: string; /** URL for the global team page. @example "https://sendou.ink/t/moonlight" */ teamPageUrl: string | null; - /** @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp" */ + /** Pickup team logos are only shown before the tournament starts to organizers and the team's own members. @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp" */ logoUrl: string | null; seed: number | null; /** Overall placement in the tournament. Null while the team is still playing. @example 5 */ @@ -158,6 +158,7 @@ export type GetTournamentTeamsResponse = Array<{ mapWins: number; mapLosses: number; } | null; + /** Only shown before the tournament starts to organizers and the team's own members. */ mapPool: Array | null; /** Non-resetting MMR used for autoseeding: average of the members' seeding power. Ranked and unranked tournaments feed separate values. */ seedingPower: { @@ -179,8 +180,8 @@ export type GetTournamentTeamsResponse = Array<{ inGameName: string | null; /** User's pronouns. @example { "subject": "he", "object": "him" } */ pronouns: Pronouns | null; - /** Switch friend code used for identification purposes. @example "1234-5678-9101" */ - friendCode: string; + /** Switch friend code used for identification purposes. Only shown to the tournament's organizers and only for 30 days after the start (120 days for leagues). @example "1234-5678-9101" */ + friendCode: string | null; /** @example "2024-01-12T20:00:00.000Z" */ joinedAt: string; }>; diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 8703e7e16..3b50431af 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -1,4 +1,4 @@ -import { sub } from "date-fns"; +import { isAfter, sub, subDays } from "date-fns"; import { type Params, redirect } from "react-router"; import { ServerConfig } from "~/config.server"; import { @@ -154,6 +154,42 @@ export function requireTournamentVisible({ throw new Response(null, { status: 404 }); } +type TournamentFriendCodeCtx = Pick< + TournamentData["ctx"], + "permissions" | "settings" | "startsAt" +>; + +/** Organizers see the participants' friend codes only for a while after the start. Leagues run for many weeks, so theirs stay visible for longer. */ +export function canSeeTournamentFriendCodes({ + ctx, + user, +}: { + ctx: TournamentFriendCodeCtx; + user: OptionalIdObject; +}) { + const friendCodeVisibilityDays = ctx.settings.isLeague ? 120 : 30; + const tournamentStartedRecently = isAfter( + databaseTimestampToDate(ctx.startsAt), + subDays(new Date(), friendCodeVisibilityDays), + ); + + return tournamentStartedRecently && hasPermission(ctx, "ORGANIZE", user); +} + +/** Pickup avatars and map pools of teams are only revealed to organizers (and the team itself) before the start. */ +export function isTournamentTeamInfoRevealed({ + tournament, + user, +}: { + tournament: Pick; + user: OptionalIdObject; +}) { + return ( + tournament.data.stage.length > 0 || + hasPermission(tournament.ctx, "ORGANIZE", user) + ); +} + /** Guards a single `_action` branch; whole-route guards use {@link tournamentFromParams} with `for: "organizer"`. */ export function requireTournamentOrganizer( tournament: Tournament, @@ -367,9 +403,7 @@ export async function tournamentTeamsFullCached({ }) { const ctx = notFoundIfNullish(await tournamentDataCached(tournamentId)); - // pickup avatars and map pools are only revealed to organizers before the start - const revealInfo = - ctx.data.stage.length > 0 || hasPermission(ctx.ctx, "ORGANIZE", user); + const revealInfo = isTournamentTeamInfoRevealed({ tournament: ctx, user }); if (ServerConfig.disableCache) { return censoredTeams({ diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index 004a37023..2c2c2cd1d 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -5,6 +5,7 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { bracketsMetaCached, + canSeeTournamentFriendCodes, requireTournamentVisible, type TournamentLayoutData, tournamentDataCached, @@ -41,15 +42,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const tournament = await tournamentDataCached(tournamentId); requireTournamentVisible({ ctx: tournament.ctx, user }); - // leagues run for many weeks, so their friend codes stay visible for longer - const friendCodeVisibilityDays = tournament.ctx.settings.isLeague ? 120 : 30; - const tournamentStartedRecently = isAfter( - databaseTimestampToDate(tournament.ctx.startsAt), - subDays(new Date(), friendCodeVisibilityDays), - ); - const showFriendCodes = - tournamentStartedRecently && - hasPermission(tournament.ctx, "ORGANIZE", user); + const showFriendCodes = canSeeTournamentFriendCodes({ + ctx: tournament.ctx, + user, + }); const showVods = tournament.ctx.isFinalized && diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index bcf51035d..df55c54f9 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,8 +1,11 @@ import type { Page } from "@playwright/test"; import { addHours, subHours } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import type { GetTournamentTeamsResponse } from "~/features/api-public/schema"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { invariant } from "~/utils/invariant"; import type { Factories } from "./helpers/factories"; import { expect, impersonate, test } from "./helpers/playwright"; import { ApiPage } from "./pages/api/api-page"; @@ -147,6 +150,31 @@ test.describe("Public API", () => { mapLosses: 2, }); }); + + test("hides private team fields from a read token that does not organize the tournament", async ({ + page, + factories, + }) => { + const { tournamentId, token } = await organizedTournament(factories, { + withPrivateTeamInfo: true, + }); + const outsider = await factories.UserFactory.create(); + const outsiderToken = await readToken(factories, outsider.id); + + // signed in as the outsider, so the fields organizers see can only come from the token + await impersonate(page, outsider.id); + + const asOrganizer = await fetchTeams(page, token, tournamentId); + const asOutsider = await fetchTeams(page, outsiderToken, tournamentId); + + expect(asOrganizer[0].mapPool).toHaveLength(2); + expect(asOrganizer[0].logoUrl).toEqual(expect.any(String)); + expect(asOrganizer[0].members[0].friendCode).toEqual(expect.any(String)); + + expect(asOutsider[0].mapPool).toBeNull(); + expect(asOutsider[0].logoUrl).toBeNull(); + expect(asOutsider[0].members[0].friendCode).toBeNull(); + }); }); test.describe("Public API - Write endpoints", () => { @@ -353,7 +381,6 @@ test.describe("Public API - Write endpoints", () => { tournamentId, name: "Api Pickup", }); - expect(createdTeam).toBeTruthy(); expect(createdTeam.members).toHaveLength(ROSTER_SIZE); const editResponse = await page.request.fetch( @@ -459,7 +486,10 @@ test.describe("Public API - Write endpoints", () => { /** A tournament the admin organizes, with teams registered and a write token to manage it with. */ async function organizedTournament( factories: Factories, - { teamCount = 1 }: { teamCount?: number } = {}, + { + teamCount = 1, + withPrivateTeamInfo = false, + }: { teamCount?: number; withPrivateTeamInfo?: boolean } = {}, ) { await factories.UserFactory.grant(ADMIN_ID, { roles: ["API_ACCESSER"] }); @@ -477,6 +507,10 @@ async function organizedTournament( await factories.TournamentTeamFactory.create({ tournamentId: tournament.id, memberUserIds: roster.map((user) => user.id), + hasAvatar: withPrivateTeamInfo, + mapPool: withPrivateTeamInfo + ? new MapPool({ TW: [], SZ: [1, 2], TC: [], RM: [], CB: [] }) + : undefined, }), ); } @@ -494,19 +528,26 @@ async function organizedTournament( }; } -async function teamByName( - page: Page, - token: string, - { tournamentId, name }: { tournamentId: number; name: string }, -) { +async function fetchTeams(page: Page, token: string, tournamentId: number) { const response = await page.request.fetch( `/api/tournament/${tournamentId}/teams`, { headers: authorized(token) }, ); expect(response.status()).toBe(200); - const teams = await response.json(); - return teams.find((team: { name: string }) => team.name === name); + return (await response.json()) as GetTournamentTeamsResponse; +} + +async function teamByName( + page: Page, + token: string, + { tournamentId, name }: { tournamentId: number; name: string }, +) { + const teams = await fetchTeams(page, token, tournamentId); + const team = teams.find((candidate) => candidate.name === name); + invariant(team, `No team named ${name} in tournament ${tournamentId}`); + + return team; } async function readToken(factories: Factories, userId: number) {