This commit is contained in:
Ryan Barlow
2026-05-29 00:15:30 +01:00
parent b043dc6c15
commit e9f3d9ebd5
11 changed files with 529 additions and 45 deletions
+3 -1
View File
@@ -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",
+44 -1
View File
@@ -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<ClanInfo | false> {
}
}
// 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<boolean | null> {
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"
+12 -1
View File
@@ -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<string | null>;
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();
+1
View File
@@ -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,
+60 -1
View File
@@ -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<string | null> = 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<string | null> {
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,6 +137,7 @@ export class UsernameInput extends LitElement {
render() {
return html`
<div class="flex items-center w-full h-full gap-2">
<div class="relative flex items-center shrink-0">
<input
type="text"
.value=${this.clanTag}
@@ -105,8 +145,17 @@ export class UsernameInput extends LitElement {
placeholder="${translateText("username.tag")}"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
aria-busy=${this.clanCheckPending ? "true" : "false"}
aria-invalid=${this.clanTagOwnershipError ? "true" : "false"}
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
${this.clanCheckPending
? html`<span
class="absolute right-1 top-1/2 -translate-y-1/2 w-3 h-3 border-2 border-white/30 border-t-white/80 rounded-full animate-spin pointer-events-none"
aria-hidden="true"
></span>`
: null}
</div>
<input
type="text"
.value=${this.baseUsername}
@@ -124,6 +173,15 @@ export class UsernameInput extends LitElement {
>
${this.validationError}
</div>`
: this.clanTagOwnershipError
? html`<div
id="clan-tag-validation-error"
class="absolute top-full left-0 z-50 mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg whitespace-nowrap"
>
${translateText(this.clanTagOwnershipError, {
tag: this.clanTag,
})}
</div>`
: null}
`;
}
@@ -151,6 +209,7 @@ export class UsernameInput extends LitElement {
}
this.clanTag = val;
this.validateAndStore();
this.startClanCheck();
}
private handleUsernameChange(e: Event) {
+6
View File
@@ -21,6 +21,12 @@ export const RefreshResponseSchema = z.object({
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
// 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
+81
View File
@@ -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<string, unknown>) => 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<boolean | null> {
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<boolean | null>,
): 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 };
+50 -23
View File
@@ -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,11 +387,49 @@ 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) {
// 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));
@@ -413,7 +441,6 @@ export async function startWorker() {
return;
}
}
}
const cosmeticResult = privilegeRefresher
.get()
@@ -463,7 +490,7 @@ export async function startWorker() {
flares,
ip,
censoredUsername,
censoredClanTag,
resolvedClanTag,
ws,
cosmeticResult.cosmetics,
publicId,
+122
View File
@@ -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<Response>) => ({
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");
});
});
+123
View File
@@ -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",
+9
View File
@@ -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");
});
});