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