Check for private fields visibility properly in tournament public API

This commit is contained in:
Kalle
2026-09-08 22:09:44 +03:00
parent aeb96018d7
commit 1787b7b8eb
8 changed files with 208 additions and 57 deletions

View File

@@ -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();
};

View File

@@ -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());
};

View File

@@ -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<Response>({ 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();

View File

@@ -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,

View File

@@ -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<StageWithMode> | 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;
}>;

View File

@@ -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<TournamentData, "ctx" | "data">;
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({

View File

@@ -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 &&

View File

@@ -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) {