mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 13:15:47 -05:00
Check for private fields visibility properly in tournament public API
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user