diff --git a/resources/lang/en.json b/resources/lang/en.json index 4329dd763..09ac66379 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -666,7 +666,9 @@ "tag": "TAG", "tag_too_short": "Clan tag must be 2-5 alphanumeric characters.", "tag_too_long": "Clan tag cannot exceed 5 characters.", - "tag_invalid_chars": "Clan tag can only contain letters and numbers." + "tag_invalid_chars": "Clan tag can only contain letters and numbers.", + "tag_not_member": "Join the {tag} clan before using its tag.", + "tag_check_failed": "Couldn't verify clan tag. Try again or remove it." }, "host_modal": { "title": "Create Private Lobby", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 9b7f62000..f8ed42b34 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -1,3 +1,4 @@ +import { clanExistsApiPath } from "../core/ApiSchemas"; import { type ClanBansResponse, ClanBansResponseSchema, @@ -16,8 +17,10 @@ import { ClanRequestsResponseSchema, JoinClanResponseSchema, } from "../core/ClanApiSchemas"; -import { getApiBase } from "./Api"; +import { getApiBase, getUserMe } from "./Api"; import { getAuthHeader } from "./Auth"; + +const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; export type { ClanBan, ClanBansResponse, @@ -125,6 +128,46 @@ export async function fetchClanDetail(tag: string): Promise { } } +// Public existence probe (no auth). null = inconclusive (timeout / error / +// unexpected status); the caller decides how to handle it. +export async function fetchClanExists(tag: string): Promise { + try { + const res = await fetch(`${getApiBase()}${clanExistsApiPath(tag)}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; + } catch { + return null; + } +} + +/** + * Client-side mirror of the server's clan-tag ownership rule (see + * resolveClanTag in Privilege.ts): members keep their tag, non-members keep a + * fictional tag, and a real clan they don't belong to — or anything we can't + * verify — is rejected. Resolves to the tag to submit (null when dropped) plus + * an i18n error key for inline feedback. The server re-checks authoritatively. + */ +export async function checkClanTagOwnership( + tag: string, +): Promise<{ tag: string | null; error: string | null }> { + const me = await getUserMe(); + const myTags = me + ? (me.player.clans ?? []).map((c) => c.tag.toUpperCase()) + : []; + if (myTags.includes(tag.toUpperCase())) { + return { tag, error: null }; + } + + const exists = await fetchClanExists(tag); + if (exists === false) return { tag, error: null }; + if (exists === true) return { tag: null, error: "username.tag_not_member" }; + return { tag: null, error: "username.tag_check_failed" }; +} + export type ClanMemberSort = | "default" | "winsTotal" diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 17f70b31f..bd86367f4 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -80,6 +80,11 @@ export interface LobbyConfig { cosmetics: PlayerCosmeticRefs; playerName: string; playerClanTag: string | null; + // In-flight clan-tag ownership check (kicked off as the player types). When + // present, the join is gated on it: it resolves to the tag to actually + // submit (null when dropped), and runs in parallel with the WS handshake so + // only the joinGame() send waits on it. + clanTagCheck?: Promise; playerRole: string | null; gameID: GameID; turnstileToken: string | null; @@ -116,7 +121,13 @@ export function joinLobby( let currentGameRunner: ClientGameRunner | null = null; - const onconnect = () => { + const onconnect = async () => { + // Gate the join on the clan-tag ownership check. The WS handshake already + // ran in parallel; only the submit waits. Strip the tag if it didn't pass — + // the server re-checks authoritatively regardless. + if (lobbyConfig.clanTagCheck !== undefined) { + lobbyConfig.playerClanTag = await lobbyConfig.clanTagCheck; + } // Always send join - server will detect reconnection via persistentID console.log(`Joining game lobby ${lobbyConfig.gameID}`); transport.joinGame(); diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46..82c9c38dd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -842,6 +842,7 @@ class Client { turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getUsername() ?? genAnonUsername(), playerClanTag: this.usernameInput?.getClanTag() ?? null, + clanTagCheck: this.usernameInput?.getClanCheck(), playerRole, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index c5defb899..895418f7d 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -10,6 +10,7 @@ import { validateClanTag, validateUsername, } from "../core/validations/username"; +import { checkClanTagOwnership } from "./ClanApi"; import { crazyGamesSDK } from "./CrazyGamesSDK"; interface LangSelectorLike { @@ -27,9 +28,19 @@ export class UsernameInput extends LitElement { @state() private clanTag: string = ""; @property({ type: String }) validationError: string = ""; + // Ownership-check feedback (i18n key) shown inline beneath the tag input. This + // is advisory only — it does not gate play; the tag is stripped on submit and + // the server re-checks authoritatively. + @state() private clanTagOwnershipError: string = ""; + @state() private clanCheckPending: boolean = false; private _isValid: boolean = true; private _lastValidatedLang: string | null = null; + // Latest in-flight ownership check. `clanCheckGen` discards stale results so + // only the most recent keystroke updates the UI / resolves the submit value. + private clanCheckGen = 0; + private clanCheck: Promise = Promise.resolve(null); + // Remove static styles since we're using Tailwind createRenderRoot() { @@ -49,6 +60,33 @@ export class UsernameInput extends LitElement { : null; } + // Resolves to the clan tag to actually submit (null when it should be + // dropped). The join flow awaits this so the ownership check — kicked off on + // input — can run in parallel with the WebSocket handshake. + public getClanCheck(): Promise { + return this.clanCheck; + } + + private startClanCheck() { + const gen = ++this.clanCheckGen; + const tag = this.clanTag; + if (tag.length === 0 || !validateClanTag(tag).isValid) { + this.clanTagOwnershipError = ""; + this.clanCheckPending = false; + this.clanCheck = Promise.resolve(null); + return; + } + this.clanTagOwnershipError = ""; + this.clanCheckPending = true; + this.clanCheck = checkClanTagOwnership(tag).then((res) => { + if (gen === this.clanCheckGen) { + this.clanTagOwnershipError = res.error ?? ""; + this.clanCheckPending = false; + } + return res.tag; + }); + } + connectedCallback() { super.connectedCallback(); this.loadStoredUsername(); @@ -89,6 +127,7 @@ export class UsernameInput extends LitElement { this.clanTag = localStorage.getItem(clanTagKey) ?? ""; this.baseUsername = storedUsername; this.validateAndStore(); + this.startClanCheck(); } else { this.baseUsername = genAnonUsername(); this.validateAndStore(); @@ -98,15 +137,25 @@ export class UsernameInput extends LitElement { render() { return html`
- +
+ + ${this.clanCheckPending + ? html`` + : null} +
${this.validationError}
` - : null} + : this.clanTagOwnershipError + ? html`
+ ${translateText(this.clanTagOwnershipError, { + tag: this.clanTag, + })} +
` + : null} `; } @@ -151,6 +209,7 @@ export class UsernameInput extends LitElement { } this.clanTag = val; this.validateAndStore(); + this.startClanCheck(); } private handleUsernameChange(e: Event) { diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 828306b2c..f60ad22c5 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -21,6 +21,12 @@ export const RefreshResponseSchema = z.object({ }); export type RefreshResponse = z.infer; +// Existence-probe path (200 = exists, 404 = not); uppercased to match the +// canonical tag form. Shared so the client probe and server enforcement agree. +export function clanExistsApiPath(tag: string): string { + return `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`; +} + export const TokenPayloadSchema = z.object({ jti: z.string(), sub: z diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 28edfe8cf..91f65d362 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -11,6 +11,7 @@ import { } from "obscenity"; import countries from "resources/countries.json"; +import { clanExistsApiPath, type UserMeResponse } from "../core/ApiSchemas"; import { Cosmetics } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; import { @@ -151,6 +152,86 @@ function censorWithMatcher( return { username: censoredName, clanTag: censoredClanTag }; } +export const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; + +interface ClanProbeDeps { + /** Base URL of the upstream auth API (issuer). */ + baseUrl: string; + /** Injected so tests can stub network behavior. */ + fetcher?: typeof fetch; + /** Logger callback for unexpected statuses / transport errors. */ + onWarn?: (event: string, ctx: Record) => void; +} + +/** + * Returns true if the tag matches a real clan upstream, false if it does not, + * and null when the result is inconclusive (transport error, timeout, or + * unexpected status). Callers treat null as fail-closed (drop the tag). + */ +export async function clanExistsByTag( + tag: string, + deps: ClanProbeDeps, +): Promise { + const fetcher = deps.fetcher ?? fetch; + try { + const response = await fetcher(`${deps.baseUrl}${clanExistsApiPath(tag)}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (response.status === 200) return true; + if (response.status === 404) return false; + deps.onWarn?.("clanExistsByTag: unexpected status, failing closed", { + tag: tag.toUpperCase(), + status: response.status, + }); + return null; + } catch (e) { + deps.onWarn?.("clanExistsByTag: fetch failed, failing closed", { + tag: tag.toUpperCase(), + error: e instanceof Error ? e.message : String(e), + }); + return null; + } +} + +/** + * Decide whether a player may wear the given (already-censored) clan tag. + * + * - Members of the tag's clan pass through unchanged. + * - Non-members keep the tag only when the API confirms no such clan exists + * (a fictional tag). + * - A real clan the player isn't in, or an inconclusive check, drops the tag + * (fail-closed) — `reason` lets callers log the impersonation attempt. + */ +export async function resolveClanTag( + censoredTag: string | null, + userMeResponse: UserMeResponse | null, + existsChecker: (tag: string) => Promise, +): Promise<{ + tag: string | null; + dropped: boolean; + reason?: "exists" | "inconclusive"; +}> { + if (censoredTag === null) return { tag: null, dropped: false }; + + const userClanTags = new Set( + userMeResponse + ? (userMeResponse.player.clans ?? []).map((c) => c.tag.toUpperCase()) + : [], + ); + if (userClanTags.has(censoredTag.toUpperCase())) { + return { tag: censoredTag, dropped: false }; + } + + const exists = await existsChecker(censoredTag); + if (exists === false) return { tag: censoredTag, dropped: false }; + return { + tag: null, + dropped: true, + reason: exists === true ? "exists" : "inconclusive", + }; +} + type CosmeticResult = | { type: "allowed"; cosmetics: PlayerCosmetics } | { type: "forbidden"; reason: string }; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 59626396a..8fb37bb70 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,6 +7,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; +import { type UserMeResponse } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { @@ -27,6 +28,7 @@ import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; import { setNoStoreHeaders } from "./NoStoreHeaders"; import { startPolling } from "./PollingLoop"; +import { clanExistsByTag, resolveClanTag } from "./Privilege"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { ServerEnv } from "./ServerEnv"; import { applyStaticAssetCacheControl } from "./StaticAssetCache"; @@ -357,29 +359,17 @@ export async function startWorker() { } // Normalize username and clan tag before any rejoin/join handling. - // If this connection maps to an existing lobby client, we still want - // the latest pre-join identity to be reflected. const { clanTag: censoredClanTag, username: censoredUsername } = privilegeRefresher .get() .censor(clientMsg.username, clientMsg.clanTag ?? null); - // Try to reconnect an existing client (e.g., page refresh) - // If successful, skip all authorization - if ( - gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { - username: censoredUsername, - clanTag: censoredClanTag, - }) - ) { - return; - } - - let flares: string[] | undefined; - let publicId: string | undefined; - let friends: string[] = []; - + // Fetch the user profile up front. It's needed here so the clan-tag + // ownership check can run *before* the reconnect path below — otherwise + // a page refresh would let a player swap to an unvalidated tag — and is + // reused for flares/cosmetics on new joins. const allowedFlares = ServerEnv.allowedFlares(); + let userMeResponse: UserMeResponse | null = null; if (claims === null) { if (allowedFlares !== undefined) { log.warn("Unauthorized: Anonymous user attempted to join game"); @@ -397,21 +387,58 @@ export async function startWorker() { ws.close(1002, "Unauthorized: user me fetch failed"); return; } - flares = result.response.player.flares; - publicId = result.response.player.publicId; - friends = result.response.player.friends; + userMeResponse = result.response; + } - if (allowedFlares !== undefined) { - const allowed = - allowedFlares.length === 0 || - allowedFlares.some((f) => flares?.includes(f)); - if (!allowed) { - log.warn( - "Forbidden: player without an allowed flare attempted to join game", - ); - ws.close(1002, "Forbidden"); - return; - } + // Enforce clan tag ownership. A player can wear a tag only if they're a + // member; if they aren't and the tag belongs to a real clan, drop it to + // prevent impersonation. Fictional tags pass through. + const resolution = await resolveClanTag( + censoredClanTag, + userMeResponse, + (tag) => + clanExistsByTag(tag, { + baseUrl: ServerEnv.jwtIssuer(), + onWarn: (event, ctx) => log.warn(event, ctx), + }), + ); + if (resolution.dropped) { + log.warn("Dropped clan tag: player is not a member", { + persistentID: persistentId, + gameID: clientMsg.gameID, + clanTag: censoredClanTag, + reason: resolution.reason, + }); + } + const resolvedClanTag = resolution.tag; + + // Try to reconnect an existing client (e.g., page refresh). Pre-game, + // username and clan tag pick up the latest validated values from this + // connection. + if ( + gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { + username: censoredUsername, + clanTag: resolvedClanTag, + }) + ) { + return; + } + + // New client — finish the join checks. + const flares = userMeResponse?.player.flares; + const publicId = userMeResponse?.player.publicId; + const friends = userMeResponse?.player.friends ?? []; + + if (userMeResponse !== null && allowedFlares !== undefined) { + const allowed = + allowedFlares.length === 0 || + allowedFlares.some((f) => flares?.includes(f)); + if (!allowed) { + log.warn( + "Forbidden: player without an allowed flare attempted to join game", + ); + ws.close(1002, "Forbidden"); + return; } } @@ -463,7 +490,7 @@ export async function startWorker() { flares, ip, censoredUsername, - censoredClanTag, + resolvedClanTag, ws, cosmeticResult.cosmetics, publicId, diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 44185833c..db8066470 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -1,6 +1,9 @@ +import type { UserMeResponse } from "../src/core/ApiSchemas"; import { + clanExistsByTag, createMatcher, PrivilegeCheckerImpl, + resolveClanTag, shadowNames, } from "../src/server/Privilege"; @@ -519,3 +522,122 @@ describe("Skin validation", () => { }); }); }); + +const okResponse = (status: number): Response => + ({ status }) as unknown as Response; + +const userWithClans = (tags: string[]): UserMeResponse => + ({ + user: {}, + player: { + publicId: "p1", + adfree: false, + flares: [], + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + clans: tags.map((tag) => ({ + tag, + name: tag, + role: "member" as const, + joinedAt: new Date().toISOString(), + memberCount: 1, + })), + }, + }) as UserMeResponse; + +describe("clanExistsByTag", () => { + const deps = (fetcher: () => Promise) => ({ + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + }); + + it("returns true on HTTP 200", async () => { + const result = await clanExistsByTag( + "ABC", + deps(async () => okResponse(200)), + ); + expect(result).toBe(true); + }); + + it("returns false on HTTP 404", async () => { + const result = await clanExistsByTag( + "XYZ", + deps(async () => okResponse(404)), + ); + expect(result).toBe(false); + }); + + it("returns null on unexpected status (fail-closed)", async () => { + const result = await clanExistsByTag( + "ABC", + deps(async () => okResponse(503)), + ); + expect(result).toBeNull(); + }); + + it("returns null on transport error (fail-closed)", async () => { + const result = await clanExistsByTag( + "ABC", + deps(async () => { + throw new Error("offline"); + }), + ); + expect(result).toBeNull(); + }); + + it("uppercases the tag in the request URL", async () => { + const fetcher = vi.fn(async () => okResponse(200)); + await clanExistsByTag("abc", deps(fetcher)); + const calledUrl = (fetcher.mock.calls[0] as unknown[])[0] as string; + expect(calledUrl).toContain("/public/clan/ABC/exists"); + }); +}); + +describe("resolveClanTag", () => { + it("passes a null tag through unchanged", async () => { + const probe = vi.fn(); + const result = await resolveClanTag(null, null, probe); + expect(result).toEqual({ tag: null, dropped: false }); + expect(probe).not.toHaveBeenCalled(); + }); + + it("accepts a tag when the user is a member (case-insensitive)", async () => { + const probe = vi.fn(); + const me = userWithClans(["abc"]); + const result = await resolveClanTag("ABC", me, probe); + expect(result).toEqual({ tag: "ABC", dropped: false }); + expect(probe).not.toHaveBeenCalled(); + }); + + it("drops a tag belonging to a real clan the user does not belong to", async () => { + const probe = vi.fn(async () => true); + const me = userWithClans(["other"]); + const result = await resolveClanTag("ABC", me, probe); + expect(result).toEqual({ tag: null, dropped: true, reason: "exists" }); + }); + + it("keeps a tag that does not match any real clan (fictional)", async () => { + const probe = vi.fn(async () => false); + const result = await resolveClanTag("ABC", null, probe); + expect(result).toEqual({ tag: "ABC", dropped: false }); + }); + + it("drops the tag on inconclusive existence check (fail-closed)", async () => { + const probe = vi.fn(async () => null); + const result = await resolveClanTag("ABC", null, probe); + expect(result).toEqual({ + tag: null, + dropped: true, + reason: "inconclusive", + }); + }); + + it("treats anonymous users as members of no clans", async () => { + const probe = vi.fn(async () => true); + const result = await resolveClanTag("ABC", null, probe); + expect(result.tag).toBeNull(); + expect(result.dropped).toBe(true); + expect(probe).toHaveBeenCalledWith("ABC"); + }); +}); diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index f55b3fb90..f01ab6b5d 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -2,20 +2,45 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/client/Api", () => ({ getApiBase: vi.fn(() => "http://localhost:3000"), + getUserMe: vi.fn(), })); vi.mock("../../../src/client/Auth", () => ({ getAuthHeader: vi.fn(async () => "Bearer test-token"), })); +import { getUserMe } from "../../../src/client/Api"; import { + checkClanTagOwnership, fetchClanDetail, + fetchClanExists, fetchClanGames, fetchClanLeaderboard, fetchClanMembers, fetchClanRequests, fetchClans, } from "../../../src/client/ClanApi"; +import type { UserMeResponse } from "../../../src/core/ApiSchemas"; + +const userWithClans = (tags: string[]): UserMeResponse => + ({ + user: {}, + player: { + publicId: "p1", + adfree: false, + flares: [], + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + clans: tags.map((tag) => ({ + tag, + name: tag, + role: "member" as const, + joinedAt: "2024-01-01T00:00:00.000Z", + memberCount: 1, + })), + }, + }) as unknown as UserMeResponse; const okJson = (data: unknown, status = 200) => ({ ok: true, @@ -37,6 +62,104 @@ beforeEach(() => { vi.clearAllMocks(); }); +describe("fetchClanExists", () => { + const status = (s: number) => ({ status: s }); + + it("returns true on HTTP 200", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(200))), + ); + await expect(fetchClanExists("ABC")).resolves.toBe(true); + }); + + it("returns false on HTTP 404", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(404))), + ); + await expect(fetchClanExists("XYZ")).resolves.toBe(false); + }); + + it("returns null on unexpected status (5xx)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(503))), + ); + await expect(fetchClanExists("ABC")).resolves.toBeNull(); + }); + + it("returns null on transport error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + await expect(fetchClanExists("ABC")).resolves.toBeNull(); + }); + + it("uppercases and URL-encodes the tag in the request URL", async () => { + const fetchSpy = vi.fn( + (_input: string | URL | Request, _init?: RequestInit) => + Promise.resolve(status(200)), + ); + vi.stubGlobal("fetch", fetchSpy); + await fetchClanExists("abc"); + const calledUrl = fetchSpy.mock.calls[0]![0] as string; + expect(calledUrl).toContain("/public/clan/ABC/exists"); + }); +}); + +describe("checkClanTagOwnership", () => { + const status = (s: number) => ({ status: s }); + + it("accepts a tag the user is a member of without probing existence", async () => { + vi.mocked(getUserMe).mockResolvedValue(userWithClans(["abc"])); + const fetchSpy = vi.fn(() => Promise.resolve(status(200))); + vi.stubGlobal("fetch", fetchSpy); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: "ABC", + error: null, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("accepts a fictional tag (clan does not exist)", async () => { + vi.mocked(getUserMe).mockResolvedValue(userWithClans(["other"])); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(404))), + ); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: "ABC", + error: null, + }); + }); + + it("rejects a real clan the user does not belong to", async () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(200))), + ); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: null, + error: "username.tag_not_member", + }); + }); + + it("rejects on an inconclusive existence check", async () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(503))), + ); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: null, + error: "username.tag_check_failed", + }); + }); +}); + describe("fetchClanLeaderboard", () => { const leaderboardData = { start: "2024-01-01T00:00:00.000Z", diff --git a/tests/core/ApiSchemas.test.ts b/tests/core/ApiSchemas.test.ts new file mode 100644 index 000000000..11148ab0e --- /dev/null +++ b/tests/core/ApiSchemas.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { clanExistsApiPath } from "../../src/core/ApiSchemas"; + +describe("clanExistsApiPath", () => { + it("uppercases and URL-encodes the tag", () => { + expect(clanExistsApiPath("abc")).toBe("/public/clan/ABC/exists"); + expect(clanExistsApiPath("a/b")).toBe("/public/clan/A%2FB/exists"); + }); +});