Add/remove member write API (#2774)

This commit is contained in:
Kalle 2026-01-25 22:13:00 +02:00 committed by GitHub
parent 832cd0b8f1
commit 675d609c20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 494 additions and 131 deletions

View File

@ -809,6 +809,12 @@ function patrons() {
patronSince: dateToDatabaseTimestamp(faker.date.past()),
patronTier: 2,
});
// Give ORG_ADMIN_TEST_ID API access without patron status
// so they don't get TOURNAMENT_ADDER role
sql
.prepare(`update user set "isApiAccesser" = 1 where id = ?`)
.run(ORG_ADMIN_TEST_ID);
}
function userIdsInRandomOrder(specialLast = false) {

View File

@ -5,6 +5,7 @@ import { z } from "zod";
import { sql } from "~/db/sql";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { SEED_VARIATIONS } from "~/features/api-private/constants";
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
@ -54,6 +55,7 @@ export const action: ActionFunction = async ({ request }) => {
await refreshBannedCache();
await refreshSendouQInstance();
await refreshTentativeTiersCache();
await refreshApiTokensCache();
return Response.json(null);
};

View File

@ -0,0 +1,39 @@
import type { ActionFunction, ActionFunctionArgs } from "react-router";
/**
* Wraps an existing action function for API use.
* Converts redirect-based success/error responses to JSON responses.
*
* The existing actions use:
* - `successToast(message)` which returns `redirect("?__success=message")`
* - `errorToastIfFalsy/errorToastIfErr` which throw `redirect("?__error=message")`
*/
export async function wrapActionForApi(
actionFn: ActionFunction,
args: ActionFunctionArgs,
): Promise<Response> {
try {
const response = await actionFn(args);
if (response instanceof Response && response.status === 302) {
return new Response(null, { status: 200 });
}
return response as Response;
} catch (e) {
if (e instanceof Response && e.status === 302) {
const location = e.headers.get("Location") ?? "";
if (location.includes("__error=")) {
const errorMsg = new URLSearchParams(location.replace("?", "")).get(
"__error",
);
return new Response(JSON.stringify({ error: errorMsg }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
throw e;
}
}

View File

@ -0,0 +1,52 @@
import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getTokenInfo } from "./api-public-utils.server";
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
function extractToken(req: Request): string | null {
const authHeader = req.headers.get("Authorization");
if (!authHeader) return null;
return authHeader.replace("Bearer ", "");
}
export const apiAuthMiddleware: MiddlewareFn = async ({ request }, next) => {
if (request.method === "OPTIONS") {
return next();
}
const token = extractToken(request);
if (!token) {
return Response.json(
{ error: "Missing Authorization header" },
{ status: 401 },
);
}
const tokenInfo = getTokenInfo(token);
if (!tokenInfo) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
if (request.method === "POST" && tokenInfo.type !== "write") {
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());
}
return next();
};

View File

@ -10,19 +10,12 @@ type MiddlewareFn = (
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
export const apiCorsMiddleware: MiddlewareFn = async ({ request }, next) => {
const url = new URL(request.url);
const isApiRoute = url.pathname.startsWith("/api/");
if (!isApiRoute) {
return next();
}
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,

View File

@ -1,35 +1,26 @@
import type { ApiTokenType } from "~/db/tables";
import * as ApiRepository from "~/features/api/ApiRepository.server";
type CachedToken = { type: ApiTokenType; userId: number };
async function loadApiTokensCache() {
const dbTokens = await ApiRepository.allApiTokens();
const tokenMap = new Map<string, ApiTokenType>();
const tokenMap = new Map<string, CachedToken>();
for (const { token, type } of dbTokens) {
tokenMap.set(token, type);
for (const { token, type, userId } of dbTokens) {
tokenMap.set(token, { type, userId });
}
return tokenMap;
}
let apiTokens = await loadApiTokensCache();
let apiTokens: Map<string, CachedToken> = await loadApiTokensCache();
export function getTokenInfo(token: string): CachedToken | undefined {
return apiTokens.get(token);
}
export async function refreshApiTokensCache() {
apiTokens = await loadApiTokensCache();
}
function extractToken(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Response("Missing Authorization header", { status: 401 });
}
return authHeader.replace("Bearer ", "");
}
export function requireBearerAuth(req: Request) {
const token = extractToken(req);
if (!apiTokens.has(token)) {
throw new Response("Invalid token", { status: 401 });
}
}

View File

@ -0,0 +1,8 @@
import { apiAuthMiddleware } from "../api-auth-middleware.server";
import { apiCorsMiddleware } from "../api-cors-middleware.server";
import type { Route } from "./+types/api.layout";
export const middleware: Route.MiddlewareFunction[] = [
apiCorsMiddleware,
apiAuthMiddleware,
];

View File

@ -7,7 +7,6 @@ import {
weekNumberToDate,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetCalendarWeekResponse } from "../schema";
const paramsSchema = z.object({
@ -15,9 +14,7 @@ const paramsSchema = z.object({
week: z.coerce.number().int().min(1).max(53),
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { week, year } = parseParams({ params, schema: paramsSchema });
const events = await fetchEventsOfWeek({

View File

@ -5,16 +5,13 @@ import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentOrganizationResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({ params, schema: paramsSchema });
const organization = notFoundIfFalsy(

View File

@ -3,16 +3,13 @@ import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetUsersActiveSendouqMatchResponse } from "../schema";
const paramsSchema = z.object({
userId: id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { userId } = parseParams({
params,
schema: paramsSchema,

View File

@ -4,16 +4,13 @@ import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.s
import { i18next } from "~/modules/i18n/i18next.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetSendouqMatchResponse, MapListMap } from "../schema";
const paramsSchema = z.object({
matchId: id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { matchId } = parseParams({
params,
schema: paramsSchema,

View File

@ -4,16 +4,13 @@ import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTeamResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: teamId } = parseParams({ params, schema: paramsSchema });
const team = notFoundIfFalsy(

View File

@ -10,16 +10,13 @@ import { i18next } from "~/modules/i18n/i18next.server";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentMatchResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT("en", ["game-misc"]);
const { id } = parseParams({
params,

View File

@ -3,7 +3,6 @@ import { z } from "zod";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentBracketStandingsResponse } from "../schema";
const paramsSchema = z.object({
@ -11,9 +10,7 @@ const paramsSchema = z.object({
bidx: z.coerce.number().int(),
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id, bidx } = parseParams({ params, schema: paramsSchema });
const tournament = await tournamentFromDB({

View File

@ -4,7 +4,6 @@ import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentBracketResponse } from "../schema";
const paramsSchema = z.object({
@ -12,9 +11,7 @@ const paramsSchema = z.object({
bidx: z.coerce.number().int(),
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id, bidx } = parseParams({ params, schema: paramsSchema });
const tournament = await tournamentFromDB({

View File

@ -3,16 +3,13 @@ import { z } from "zod";
import { db } from "~/db/sql";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetCastedTournamentMatchesResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,

View File

@ -3,16 +3,13 @@ import { z } from "zod";
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentPlayersResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,

View File

@ -0,0 +1,42 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId, teamId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId } = await parseBody({
request: args.request,
schema: bodySchema,
});
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "ADD_MEMBER",
teamId,
userId,
}),
});
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,
});
};

View File

@ -0,0 +1,42 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId, teamId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId } = await parseBody({
request: args.request,
schema: bodySchema,
});
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "REMOVE_MEMBER",
teamId,
memberId: userId,
}),
});
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,
});
};

View File

@ -10,16 +10,13 @@ import { databaseTimestampToDate } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentTeamsResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT("en", ["game-misc"]);
const { id } = parseParams({
params,

View File

@ -5,16 +5,13 @@ import { db } from "~/db/sql";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({ params, schema: paramsSchema });
const tournament = notFoundIfFalsy(

View File

@ -7,16 +7,13 @@ import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { safeNumberParse } from "~/utils/number";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetUserResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
requireBearerAuth(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT("en", ["weapons"]);
const { identifier } = parseParams({ params, schema: paramsSchema });

View File

@ -501,3 +501,11 @@ type TournamentBracket = {
};
type TournamentBracketData = ValueToArray<DataTypes>;
/** POST /api/tournament/{id}/teams/{teamId}/add-member */
/** POST /api/tournament/{id}/teams/{teamId}/remove-member */
/** @lintignore */
export interface TournamentTeamMemberBody {
userId: number;
}

View File

@ -52,7 +52,7 @@ export async function allApiTokens() {
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select(["ApiToken.token", "ApiToken.type"])
.select(["ApiToken.token", "ApiToken.type", "ApiToken.userId"])
// NOTE: permissions logic also exists in checkUserHasApiAccess function
.where((eb) =>
eb.or([
@ -72,5 +72,9 @@ export async function allApiTokens() {
.groupBy("ApiToken.token")
.execute();
return tokens.map((row) => ({ token: row.token, type: row.type }));
return tokens.map((row) => ({
token: row.token,
type: row.type,
userId: row.userId,
}));
}

View File

@ -34,7 +34,6 @@ import { Catcher } from "./components/Catcher";
import { SendouToastRegion, toastQueue } from "./components/elements/Toast";
import { Layout } from "./components/layout";
import { Ramp } from "./components/ramp/Ramp";
import { apiCorsMiddleware } from "./features/api-public/api-cors-middleware.server";
import { getUser } from "./features/auth/core/user.server";
import { userMiddleware } from "./features/auth/core/user-middleware.server";
import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server";
@ -55,7 +54,6 @@ import { isRevalidation, metaTags, type SerializeFrom } from "./utils/remix";
export const middleware: Route.MiddlewareFunction[] = [
sessionIdMiddleware,
apiCorsMiddleware,
userMiddleware,
];

View File

@ -1,5 +1,6 @@
import {
index,
layout,
prefix,
type RouteConfig,
route,
@ -253,54 +254,64 @@ export default [
route("/seed", "features/api-private/routes/seed.ts"),
route("/users", "features/api-private/routes/users.ts"),
...prefix("/api", [
route(
"/user/:identifier",
"features/api-public/routes/user.$identifier.ts",
),
route(
"/user/:identifier/ids",
"features/api-public/routes/user.$identifier.ids.ts",
),
route(
"/calendar/:year/:week",
"features/api-public/routes/calendar.$year.$week.ts",
),
route(
"/sendouq/active-match/:userId",
"features/api-public/routes/sendouq.active-match.$userId.ts",
),
route(
"/sendouq/match/:matchId",
"features/api-public/routes/sendouq.match.$matchId.ts",
),
route("/tournament/:id", "features/api-public/routes/tournament.$id.ts"),
route(
"/tournament/:id/teams",
"features/api-public/routes/tournament.$id.teams.ts",
),
route(
"/tournament/:id/players",
"features/api-public/routes/tournament.$id.players.ts",
),
route(
"/tournament/:id/casted",
"features/api-public/routes/tournament.$id.casted.ts",
),
route(
"/tournament/:id/brackets/:bidx",
"features/api-public/routes/tournament.$id.brackets.$bidx.ts",
),
route(
"/tournament/:id/brackets/:bidx/standings",
"features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts",
),
route(
"/tournament-match/:id",
"features/api-public/routes/tournament-match.$id.ts",
),
route("/org/:id", "features/api-public/routes/org.$id.ts"),
route("/team/:id", "features/api-public/routes/team.$id.ts"),
layout("features/api-public/routes/api.layout.tsx", [
...prefix("/api", [
route(
"/user/:identifier",
"features/api-public/routes/user.$identifier.ts",
),
route(
"/user/:identifier/ids",
"features/api-public/routes/user.$identifier.ids.ts",
),
route(
"/calendar/:year/:week",
"features/api-public/routes/calendar.$year.$week.ts",
),
route(
"/sendouq/active-match/:userId",
"features/api-public/routes/sendouq.active-match.$userId.ts",
),
route(
"/sendouq/match/:matchId",
"features/api-public/routes/sendouq.match.$matchId.ts",
),
route("/tournament/:id", "features/api-public/routes/tournament.$id.ts"),
route(
"/tournament/:id/teams",
"features/api-public/routes/tournament.$id.teams.ts",
),
route(
"/tournament/:id/players",
"features/api-public/routes/tournament.$id.players.ts",
),
route(
"/tournament/:id/casted",
"features/api-public/routes/tournament.$id.casted.ts",
),
route(
"/tournament/:id/brackets/:bidx",
"features/api-public/routes/tournament.$id.brackets.$bidx.ts",
),
route(
"/tournament/:id/brackets/:bidx/standings",
"features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts",
),
route(
"/tournament-match/:id",
"features/api-public/routes/tournament-match.$id.ts",
),
route("/org/:id", "features/api-public/routes/org.$id.ts"),
route("/team/:id", "features/api-public/routes/team.$id.ts"),
route(
"/tournament/:id/teams/:teamId/add-member",
"features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts",
),
route(
"/tournament/:id/teams/:teamId/remove-member",
"features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts",
),
]),
]),
route("/short/:customUrl", "features/user-page/routes/short.$customUrl.ts"),

View File

@ -131,6 +131,22 @@ export function parseParams<T extends z.ZodTypeAny>({
return parsed.data;
}
/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */
export async function parseBody<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<z.infer<T>> {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) {
throw new Response(null, { status: 400 });
}
return parsed.data;
}
export async function safeParseRequestFormData<T extends z.ZodTypeAny>({
request,
schema,

View File

@ -1,5 +1,27 @@
import type { Page } from "@playwright/test";
import { ORG_ADMIN_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
import { tournamentTeamPage } from "~/utils/urls";
const ITZ_TOURNAMENT_ID = 2;
const ITZ_TEAM_ID = 101;
const USER_NOT_ON_ITZ_TEAM = 100;
async function generateWriteToken(page: Page): Promise<string> {
await navigate({ page, url: "/api" });
// Click the second form (write token)
await page.locator("form").nth(1).getByRole("button").click();
await page.waitForURL("/api");
// Reveal and get the write token
// After generating only the write token, there's just one reveal button (for write)
await page.getByRole("button", { name: /reveal/i }).click();
const token = await page.locator("input[readonly]").inputValue();
return token;
}
test.describe("Public API", () => {
test("OPTIONS preflight request returns 204 with CORS headers", async ({
@ -57,3 +79,171 @@ test.describe("Public API", () => {
expect(data.name).toBe("Sendou");
});
});
test.describe("Public API - Write endpoints", () => {
test("adds member to tournament team via API", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
const token = await generateWriteToken(page);
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
},
);
expect(response.status()).toBe(200);
// Verify in UI that member was added
await navigate({
page,
url: tournamentTeamPage({
tournamentId: ITZ_TOURNAMENT_ID,
tournamentTeamId: ITZ_TEAM_ID,
}),
});
// User 100 should be visible on the team page
await expect(page.getByTestId("team-member-name")).toHaveCount(5);
});
test("removes member from tournament team via API", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
const token = await generateWriteToken(page);
// First add the member
await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
},
);
// Verify member was added
await navigate({
page,
url: tournamentTeamPage({
tournamentId: ITZ_TOURNAMENT_ID,
tournamentTeamId: ITZ_TEAM_ID,
}),
});
await expect(page.getByTestId("team-member-name")).toHaveCount(5);
// Remove the member via API
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/remove-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
},
);
expect(response.status()).toBe(200);
// Verify in UI that member was removed
await page.reload();
await expect(page.getByTestId("team-member-name")).toHaveCount(4);
});
test("returns 401 for invalid token", async ({ page }) => {
await seed(page);
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
{
method: "POST",
headers: {
Authorization: "Bearer invalid-token-12345",
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
},
);
expect(response.status()).toBe(401);
const data = await response.json();
expect(data.error).toBe("Invalid token");
});
test("returns 403 when using read token for write endpoint", async ({
page,
}) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: "/api" });
// Click the first form (read token)
await page.locator("form").first().getByRole("button").click();
await page.waitForURL("/api");
// Reveal and get the read token
await page
.getByRole("button", { name: /reveal/i })
.first()
.click();
const readToken = await page
.locator("input[readonly]")
.first()
.inputValue();
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${readToken}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
},
);
expect(response.status()).toBe(403);
const data = await response.json();
expect(data.error).toBe("Write token required");
});
test("returns 400 when user is not the organizer of this tournament", async ({
page,
}) => {
await seed(page);
await impersonate(page, ORG_ADMIN_TEST_ID);
const token = await generateWriteToken(page);
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
},
);
expect(response.status()).toBe(400);
const data = await response.json();
expect(data.error).toBe("Unauthorized");
});
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.