From 4cf234fb7a10283c484094e48621ec39f2229725 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 25 May 2026 18:58:07 +0100 Subject: [PATCH] update --- src/client/ClanApi.ts | 32 +++- src/client/ClanTagInput.ts | 207 +++++++++++++++++++++++ src/client/GameModeSelector.ts | 9 +- src/client/LangSelector.ts | 1 + src/client/Main.ts | 21 ++- src/client/SinglePlayerModal.ts | 6 +- src/client/UsernameInput.ts | 188 ++------------------ src/client/components/PlayPage.ts | 3 + src/core/ApiSchemas.ts | 19 +++ src/server/jwt.ts | 88 +++++++++- tests/client/ClanTagInput.test.ts | 203 ++++++++++++++++++++++ tests/client/clan/ClanApiQueries.test.ts | 61 +++++++ tests/server/GameLifecycle.test.ts | 112 ++++++++++++ tests/server/JwtClanExists.test.ts | 141 +++++++++++++++ 14 files changed, 900 insertions(+), 191 deletions(-) create mode 100644 src/client/ClanTagInput.ts create mode 100644 tests/client/ClanTagInput.test.ts create mode 100644 tests/server/JwtClanExists.test.ts diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 71eb62a45..36a8ab104 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -1,3 +1,7 @@ +import { + ClanExistsResponseSchema, + clanExistsApiPath, +} from "../core/ApiSchemas"; import { type ClanBansResponse, ClanBansResponseSchema, @@ -18,6 +22,8 @@ import { } from "../core/ClanApiSchemas"; import { getApiBase } from "./Api"; import { getAuthHeader } from "./Auth"; + +const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; export type { ClanBan, ClanBansResponse, @@ -30,9 +36,9 @@ export type { ClanInfo, ClanJoinRequest, ClanMember, - ClanMembersResponse, ClanMemberStats, ClanMemberWL, + ClanMembersResponse, ClanRequestsResponse, } from "../core/ClanApiSchemas"; @@ -127,14 +133,26 @@ export async function fetchClanDetail(tag: string): Promise { // Lightweight existence probe. Public endpoint, no auth required — used to // detect clan-tag ownership conflicts when a user types a tag into the input. -// Returns null on unexpected statuses so callers can fail open. +// Returns null on unexpected statuses, timeouts, or transport errors so callers +// can fail open. export async function fetchClanExists(tag: string): Promise { try { - const res = await fetch( - `${getApiBase()}/public/clan/${encodeURIComponent(tag)}/exists`, - { headers: { Accept: "application/json" } }, - ); - if (res.status === 200) return true; + const res = await fetch(`${getApiBase()}${clanExistsApiPath(tag)}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (res.status === 200) { + try { + const text = await res.text(); + if (text.length > 0) { + const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); + if (parsed.success && parsed.data?.exists === false) return false; + } + } catch { + // Body parsing is forward-compat only; ignore failures. + } + return true; + } if (res.status === 404) return false; return null; } catch { diff --git a/src/client/ClanTagInput.ts b/src/client/ClanTagInput.ts new file mode 100644 index 000000000..5200f875d --- /dev/null +++ b/src/client/ClanTagInput.ts @@ -0,0 +1,207 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { translateText } from "../client/Utils"; +import { sanitizeClanTag } from "../core/Util"; +import { + MAX_CLAN_TAG_LENGTH, + MIN_CLAN_TAG_LENGTH, + validateClanTag, +} from "../core/validations/username"; +import { getUserMe } from "./Api"; +import { fetchClanExists } from "./ClanApi"; + +const CLAN_OWNERSHIP_DEBOUNCE_MS = 400; +const clanTagKey = "clanTag"; + +interface LangSelectorLike { + currentLang?: string; + translations?: Record; + defaultTranslations?: Record; +} + +@customElement("clan-tag-input") +export class ClanTagInput extends LitElement { + @state() private clanTag: string = ""; + + @property({ type: String }) validationError: string = ""; + + private formatError: string = ""; + private ownershipError: string = ""; + private checkCounter: number = 0; + private checkTimer: ReturnType | null = null; + private lastTranslatedLang: string | null = null; + + createRenderRoot() { + return this; + } + + public isValid(): boolean { + return this.formatError === "" && this.ownershipError === ""; + } + + public getValue(): string | null { + return this.clanTag.length >= MIN_CLAN_TAG_LENGTH && + this.clanTag.length <= MAX_CLAN_TAG_LENGTH && + validateClanTag(this.clanTag).isValid + ? this.clanTag + : null; + } + + connectedCallback() { + super.connectedCallback(); + this.clanTag = localStorage.getItem(clanTagKey) ?? ""; + this.validate(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.checkTimer !== null) { + clearTimeout(this.checkTimer); + this.checkTimer = null; + } + this.checkCounter++; // cancel any in-flight async check + } + + protected updated(): void { + // Re-validate when translations finish loading so the initial error + // (which may have been built from raw keys) gets re-translated. + if (!this.validationError) return; + const ls = document.querySelector( + "lang-selector", + ); + const lang = ls?.currentLang; + const hasTranslations = ls?.translations ?? ls?.defaultTranslations; + if (hasTranslations && lang && lang !== this.lastTranslatedLang) { + this.lastTranslatedLang = lang; + this.validate(); + } + } + + render() { + return html` +
+ + ${this.validationError + ? html`
+ ${this.validationError} +
` + : null} +
+ `; + } + + private handleInput(e: Event) { + const input = e.target as HTMLInputElement; + const sanitized = sanitizeClanTag(input.value); + if (input.value.toUpperCase() !== sanitized) { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("username.tag_invalid_chars"), + color: "red", + duration: 2000, + }, + }), + ); + } + input.value = sanitized; + this.clanTag = sanitized; + this.validate(); + } + + private validate() { + const tag = this.clanTag; + const result = validateClanTag(tag); + this.formatError = result.isValid ? "" : (result.error ?? ""); + + // Cancel any pending/in-flight ownership check. + if (this.checkTimer !== null) clearTimeout(this.checkTimer); + this.checkTimer = null; + this.checkCounter++; + + if (!result.isValid || tag.length === 0) { + // Nothing to ask the server about — clear any old ownership error, + // and remember the cleared/short value across reloads. + this.ownershipError = ""; + if (result.isValid) localStorage.setItem(clanTagKey, ""); + } else { + this.checkTimer = setTimeout(() => { + this.checkTimer = null; + void this.checkOwnership(tag); + }, CLAN_OWNERSHIP_DEBOUNCE_MS); + } + + this.refreshError(); + } + + // Are you a member? If not, does the clan exist? If it doesn't (fictional) + // or the check fails open, accept. Otherwise reject. + private async checkOwnership(tag: string) { + const checkId = this.checkCounter; + const stillCurrent = () => + checkId === this.checkCounter && this.clanTag === tag; + + const me = await getUserMe(); + if (!stillCurrent()) return; + const myTags = me + ? (me.player.clans ?? []).map((c) => c.tag.toUpperCase()) + : []; + + if (!myTags.includes(tag.toUpperCase())) { + const exists = await fetchClanExists(tag); + if (!stillCurrent()) return; + if (exists === true) { + this.reject(tag); + return; + } + } + this.accept(tag); + } + + private accept(tag: string) { + this.ownershipError = ""; + localStorage.setItem(clanTagKey, tag); + this.refreshError(); + } + + private reject(tag: string) { + this.ownershipError = translateText("username.tag_not_member", { tag }); + localStorage.removeItem(clanTagKey); + this.refreshError(); + } + + private refreshError() { + const next = this.formatError || this.ownershipError; + if (this.validationError !== next) { + this.validationError = next; + this.requestUpdate(); + } + } + + public showValidationFeedback() { + const message = + this.validationError || translateText("username.tag_invalid_chars"); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { message, color: "red", duration: 2500 }, + }), + ); + } + + public validateOrShowError(): boolean { + if (this.isValid()) return true; + this.showValidationFeedback(); + return false; + } +} diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 660dd222a..129ea3d3e 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -10,6 +10,7 @@ import { Trios, } from "../core/game/Game"; import { PublicGameInfo, PublicGames } from "../core/Schemas"; +import { ClanTagInput } from "./ClanTagInput"; import "./components/IOSAddToHomeScreenBanner"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { HostLobbyModal } from "./HostLobbyModal"; @@ -46,10 +47,14 @@ export class GameModeSelector extends LitElement { } /** - * Validates username input and shows error message if invalid. - * Returns true if valid, false otherwise. + * Validates username and clan tag inputs and shows error messages if invalid. + * Returns true if both are valid, false otherwise. */ private validateUsername(): boolean { + const clanTagInput = document.querySelector( + "clan-tag-input", + ) as ClanTagInput | null; + if (clanTagInput && !clanTagInput.validateOrShowError()) return false; const usernameInput = document.querySelector( "username-input", ) as UsernameInput | null; diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 97d369d68..ac5290fa6 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -221,6 +221,7 @@ export class LangSelector extends LitElement { "help-modal", "settings-modal", "username-input", + "clan-tag-input", "game-mode-selector", "user-setting", "o-modal", diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46..1b1f2f776 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -21,6 +21,8 @@ import "./AccountModal"; import { getUserMe, invalidateUserMe } from "./Api"; import { userAuth } from "./Auth"; import "./ClanModal"; +import "./ClanTagInput"; +import { ClanTagInput } from "./ClanTagInput"; import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner"; import { getPlayerCosmeticsRefs } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -251,6 +253,7 @@ class Client { private currentUrl: string | null = null; private usernameInput: UsernameInput | null = null; + private clanTagInput: ClanTagInput | null = null; private flagInput: FlagInput | null = null; private hostModal: HostPrivateLobbyModal; @@ -361,6 +364,13 @@ class Client { console.warn("Username input element not found"); } + this.clanTagInput = document.querySelector( + "clan-tag-input", + ) as ClanTagInput; + if (!this.clanTagInput) { + console.warn("Clan tag input element not found"); + } + this.gameModeSelector = document.querySelector( "game-mode-selector", ) as GameModeSelector; @@ -814,6 +824,9 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; this.mostRecentJoinEvent = event.timeStamp; + if (this.clanTagInput && !this.clanTagInput.validateOrShowError()) { + return; + } if (this.usernameInput && !this.usernameInput.validateOrShowError()) { return; } @@ -841,7 +854,7 @@ class Client { cosmetics: await getPlayerCosmeticsRefs(), turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getUsername() ?? genAnonUsername(), - playerClanTag: this.usernameInput?.getClanTag() ?? null, + playerClanTag: this.clanTagInput?.getValue() ?? null, playerRole, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, @@ -862,6 +875,12 @@ class Client { // fix edge case where username-validation-error is re-rendered and hidden tag removed this.usernameInput.validationError = ""; } + if (this.clanTagInput) { + this.clanTagInput.validationError = ""; + } + document + .getElementById("clan-tag-validation-error") + ?.classList.add("hidden"); document .getElementById("username-validation-error") ?.classList.add("hidden"); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 10df43b81..d2d2f2291 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -14,6 +14,7 @@ import { import { TeamCountConfig } from "../core/Schemas"; import { generateID } from "../core/Util"; import { hasLinkedAccount } from "./Api"; +import { ClanTagInput } from "./ClanTagInput"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; @@ -646,6 +647,9 @@ export class SinglePlayerModal extends BaseModal { const usernameInput = document.querySelector( "username-input", ) as UsernameInput; + const clanTagInput = document.querySelector( + "clan-tag-input", + ) as ClanTagInput | null; await crazyGamesSDK.requestMidgameAd(); @@ -659,7 +663,7 @@ export class SinglePlayerModal extends BaseModal { { clientID, username: usernameInput.getUsername(), - clanTag: usernameInput.getClanTag() ?? null, + clanTag: clanTagInput?.getValue() ?? null, cosmetics: await getPlayerCosmetics(), }, ], diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 23d5c72b5..2939b65b2 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -1,21 +1,13 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { generateCryptoRandomUUID, translateText } from "../client/Utils"; -import { sanitizeClanTag } from "../core/Util"; import { - MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, - MIN_CLAN_TAG_LENGTH, MIN_USERNAME_LENGTH, - validateClanTag, validateUsername, } from "../core/validations/username"; -import { getUserMe } from "./Api"; -import { fetchClanExists } from "./ClanApi"; import { crazyGamesSDK } from "./CrazyGamesSDK"; -const CLAN_OWNERSHIP_DEBOUNCE_MS = 400; - interface LangSelectorLike { currentLang?: string; translations?: Record; @@ -23,23 +15,14 @@ interface LangSelectorLike { } const usernameKey: string = "username"; -const clanTagKey: string = "clanTag"; @customElement("username-input") export class UsernameInput extends LitElement { @state() private baseUsername: string = ""; - @state() private clanTag: string = ""; @property({ type: String }) validationError: string = ""; private _isValid: boolean = true; private _lastValidatedLang: string | null = null; - private syncValidationError: string = ""; - private syncIsValid: boolean = true; - private clanTagAsyncError: string = ""; - private clanTagCheckCounter: number = 0; - private clanTagCheckTimer: ReturnType | null = null; - - // Remove static styles since we're using Tailwind createRenderRoot() { // Disable shadow DOM to allow Tailwind classes to work @@ -50,14 +33,6 @@ export class UsernameInput extends LitElement { return this.baseUsername.trim(); } - public getClanTag(): string | null { - return this.clanTag.length >= MIN_CLAN_TAG_LENGTH && - this.clanTag.length <= MAX_CLAN_TAG_LENGTH && - validateClanTag(this.clanTag).isValid - ? this.clanTag - : null; - } - connectedCallback() { super.connectedCallback(); this.loadStoredUsername(); @@ -95,7 +70,6 @@ export class UsernameInput extends LitElement { private loadStoredUsername() { const storedUsername = localStorage.getItem(usernameKey); if (storedUsername) { - this.clanTag = localStorage.getItem(clanTagKey) ?? ""; this.baseUsername = storedUsername; this.validateAndStore(); } else { @@ -106,16 +80,7 @@ export class UsernameInput extends LitElement { render() { return html` -
- +
+ ${this.validationError + ? html`
+ ${this.validationError} +
` + : null}
- ${this.validationError - ? html`
- ${this.validationError} -
` - : null} `; } - private handleClanTagChange(e: Event) { - const input = e.target as HTMLInputElement; - const originalValue = input.value; - const val = sanitizeClanTag(originalValue); - // Only show toast if characters were actually removed (not just uppercased) - if (originalValue.toUpperCase() !== val) { - input.value = val; - // Show toast when invalid characters are removed - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: translateText("username.tag_invalid_chars"), - color: "red", - duration: 2000, - }, - }), - ); - } else if (originalValue !== val) { - // Just update the input without toast if only case changed - input.value = val; - } - this.clanTag = val; - this.validateAndStore(); - } - private handleUsernameChange(e: Event) { const input = e.target as HTMLInputElement; const originalValue = input.value; const val = originalValue.replace(/[[\]]/g, ""); if (originalValue !== val) { input.value = val; - // Show toast when brackets are removed window.dispatchEvent( new CustomEvent("show-message", { detail: { @@ -185,109 +124,14 @@ export class UsernameInput extends LitElement { private validateAndStore() { const trimmedBase = this.getUsername(); - - const clanTagResult = validateClanTag(this.clanTag); - if (!clanTagResult.isValid) { - this.syncIsValid = false; - this.syncValidationError = clanTagResult.error ?? ""; + const result = validateUsername(trimmedBase); + this._isValid = result.isValid; + if (result.isValid) { + localStorage.setItem(usernameKey, trimmedBase); + this.validationError = ""; } else { - const result = validateUsername(trimmedBase); - this.syncIsValid = result.isValid; - if (result.isValid) { - localStorage.setItem(usernameKey, trimmedBase); - // clanTag is persisted by scheduleClanTagOwnershipCheck (or its async - // continuation) so we never store a tag the server would reject. - this.syncValidationError = ""; - } else { - this.syncValidationError = result.error ?? ""; - } + this.validationError = result.error ?? ""; } - - this.scheduleClanTagOwnershipCheck(); - this.updateValidationState(); - } - - private persistClanTag(tag: string) { - if (this.syncIsValid) { - localStorage.setItem(clanTagKey, tag); - } - } - - private updateValidationState() { - if (!this.syncIsValid) { - this._isValid = false; - this.validationError = this.syncValidationError; - return; - } - if (this.clanTagAsyncError) { - this._isValid = false; - this.validationError = this.clanTagAsyncError; - return; - } - this._isValid = true; - this.validationError = ""; - } - - private scheduleClanTagOwnershipCheck() { - if (this.clanTagCheckTimer !== null) { - clearTimeout(this.clanTagCheckTimer); - this.clanTagCheckTimer = null; - } - const tag = this.clanTag; - if ( - tag.length < MIN_CLAN_TAG_LENGTH || - tag.length > MAX_CLAN_TAG_LENGTH || - !validateClanTag(tag).isValid - ) { - // Bump the counter so any in-flight check is discarded. - this.clanTagCheckCounter++; - if (this.clanTagAsyncError) { - this.clanTagAsyncError = ""; - } - // No async check needed — persist the (empty/short) value so clearing - // the tag is remembered across reloads. - this.persistClanTag(this.getClanTag() ?? ""); - return; - } - this.clanTagCheckTimer = setTimeout(() => { - this.clanTagCheckTimer = null; - void this.runClanTagOwnershipCheck(tag); - }, CLAN_OWNERSHIP_DEBOUNCE_MS); - } - - private async runClanTagOwnershipCheck(expectedTag: string) { - const checkId = ++this.clanTagCheckCounter; - const stillCurrent = () => - checkId === this.clanTagCheckCounter && this.clanTag === expectedTag; - - const me = await getUserMe(); - if (!stillCurrent()) return; - if (me) { - const myTags = (me.player.clans ?? []).map((c) => c.tag.toUpperCase()); - if (myTags.includes(expectedTag.toUpperCase())) { - this.setClanTagAsyncError(""); - this.persistClanTag(expectedTag); - return; - } - } - - const exists = await fetchClanExists(expectedTag); - if (!stillCurrent()) return; - if (exists === true) { - this.setClanTagAsyncError( - translateText("username.tag_not_member", { tag: expectedTag }), - ); - } else { - this.setClanTagAsyncError(""); - this.persistClanTag(expectedTag); - } - } - - private setClanTagAsyncError(error: string) { - if (this.clanTagAsyncError === error) return; - this.clanTagAsyncError = error; - this.updateValidationState(); - this.requestUpdate(); } public isValid(): boolean { diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index ef33aa100..b2585c879 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -82,6 +82,9 @@ export class PlayPage extends LitElement { class="px-2 py-2 bg-surface border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl" >
+ diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 828306b2c..03f665198 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -21,6 +21,25 @@ export const RefreshResponseSchema = z.object({ }); export type RefreshResponse = z.infer; +// Auth API path for the clan existence probe. Uppercased here so the URL +// matches the canonical tag form (membership checks also uppercase), avoiding +// case-sensitivity mismatches against the upstream endpoint. +export function clanExistsApiPath(tag: string): string { + return `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`; +} + +// The upstream contract uses HTTP status alone (200 = exists, 404 = not). +// This schema is kept for forward-compat in case a body is added; today it +// matches an empty/absent body too. +export const ClanExistsResponseSchema = z + .object({ + exists: z.boolean().optional(), + }) + .partial() + .or(z.null()) + .or(z.undefined()); +export type ClanExistsResponse = z.infer; + export const TokenPayloadSchema = z.object({ jti: z.string(), sub: z diff --git a/src/server/jwt.ts b/src/server/jwt.ts index 135466150..0904796d4 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -1,6 +1,8 @@ import { jwtVerify } from "jose"; import { z } from "zod"; import { + clanExistsApiPath, + ClanExistsResponseSchema, TokenPayload, TokenPayloadSchema, UserMeResponse, @@ -8,8 +10,14 @@ import { } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { PersistentIdSchema } from "../core/Schemas"; +import { logger } from "./Logger"; import { ServerEnv } from "./ServerEnv"; +const log = logger.child({ comp: "jwt" }); + +const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; +const CLAN_EXISTS_CACHE_TTL_MS = 60_000; + type TokenVerificationResult = | { type: "success"; @@ -69,9 +77,9 @@ export async function getUserMe( | { type: "error"; message: string } > { try { - // Get the user object const response = await fetch(ServerEnv.jwtIssuer() + "/users/@me", { headers: { + Accept: "application/json", authorization: `Bearer ${token}`, "x-api-key": ServerEnv.apiKey(), }, @@ -99,17 +107,81 @@ export async function getUserMe( } } +// Module-level TTL cache. Clan existence is stable, so a short cache prevents +// repeated upstream calls during lobby-start surges. +const clanExistsCache = new Map< + string, + { result: boolean; expiresAt: number } +>(); + +function cacheGet(key: string): boolean | undefined { + const entry = clanExistsCache.get(key); + if (entry === undefined) return undefined; + if (Date.now() >= entry.expiresAt) { + clanExistsCache.delete(key); + return undefined; + } + return entry.result; +} + +function cacheSet(key: string, result: boolean) { + clanExistsCache.set(key, { + result, + expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, + }); +} + +// For tests. +export function _clearClanExistsCacheForTest() { + clanExistsCache.clear(); +} + // Best-effort check: does a clan with this tag exist? -// Returns null on transport errors or unexpected statuses so callers can -// fail open — the goal is impersonation prevention, not availability blocker. +// Returns null on transport errors, timeouts, or unexpected statuses so callers +// can fail open — the goal is impersonation prevention, not an availability +// blocker. Logs a warn on unexpected statuses so outages are observable. export async function clanExistsByTag(tag: string): Promise { + const cacheKey = tag.toUpperCase(); + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + try { - const url = `${ServerEnv.jwtIssuer()}/public/clan/${encodeURIComponent(tag)}/exists`; - const response = await fetch(url); - if (response.status === 200) return true; - if (response.status === 404) return false; + const url = `${ServerEnv.jwtIssuer()}${clanExistsApiPath(tag)}`; + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (response.status === 200) { + // The upstream may eventually start returning a body; tolerate either. + try { + const text = await response.text(); + if (text.length > 0) { + const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); + if (parsed.success && parsed.data?.exists === false) { + cacheSet(cacheKey, false); + return false; + } + } + } catch { + // Body parsing is forward-compat only; ignore failures. + } + cacheSet(cacheKey, true); + return true; + } + if (response.status === 404) { + cacheSet(cacheKey, false); + return false; + } + log.warn("clanExistsByTag: unexpected status, failing open", { + tag: cacheKey, + status: response.status, + }); return null; - } catch { + } catch (e) { + log.warn("clanExistsByTag: fetch failed, failing open", { + tag: cacheKey, + error: e instanceof Error ? e.message : String(e), + }); return null; } } diff --git a/tests/client/ClanTagInput.test.ts b/tests/client/ClanTagInput.test.ts new file mode 100644 index 000000000..3672e34c9 --- /dev/null +++ b/tests/client/ClanTagInput.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function createMockLocalStorage(): Storage { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + get length() { + return Object.keys(store).length; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + }; +} + +vi.mock("../../src/client/Api", () => ({ + getUserMe: vi.fn(), +})); + +vi.mock("../../src/client/ClanApi", () => ({ + fetchClanExists: vi.fn(), +})); + +import { getUserMe } from "../../src/client/Api"; +import { fetchClanExists } from "../../src/client/ClanApi"; +import { ClanTagInput } from "../../src/client/ClanTagInput"; + +const flushPromises = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(getUserMe).mockReset(); + vi.mocked(fetchClanExists).mockReset(); + vi.stubGlobal("localStorage", createMockLocalStorage()); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("ClanTagInput async ownership check", () => { + it("surfaces tag_not_member when user is not a member and clan exists", async () => { + vi.mocked(getUserMe).mockResolvedValue({ + user: {}, + player: { + publicId: "p1", + adfree: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + clans: [], + }, + } as any); + vi.mocked(fetchClanExists).mockResolvedValue(true); + + const input = new ClanTagInput(); + (input as any).clanTag = "ABC"; + (input as any).validate(); + + vi.advanceTimersByTime(401); + await flushPromises(); + await flushPromises(); + + expect(input.isValid()).toBe(false); + expect((input as any).ownershipError).toBe("username.tag_not_member"); + }); + + it("clears any stored clanTag when async detects ownership conflict", async () => { + vi.mocked(getUserMe).mockResolvedValue({ + user: {}, + player: { + publicId: "p1", + adfree: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + clans: [], + }, + } as any); + vi.mocked(fetchClanExists).mockResolvedValue(true); + localStorage.setItem("clanTag", "ABC"); + + const input = new ClanTagInput(); + (input as any).clanTag = "ABC"; + (input as any).validate(); + + vi.advanceTimersByTime(401); + await flushPromises(); + await flushPromises(); + + expect(localStorage.getItem("clanTag")).toBeNull(); + }); + + it("keeps the tag when the clan does not exist (fictional)", async () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.mocked(fetchClanExists).mockResolvedValue(false); + + const input = new ClanTagInput(); + (input as any).clanTag = "FIC"; + (input as any).validate(); + + vi.advanceTimersByTime(401); + await flushPromises(); + await flushPromises(); + + expect(input.isValid()).toBe(true); + expect(localStorage.getItem("clanTag")).toBe("FIC"); + }); + + it("fails open: keeps the tag when existence check returns null", async () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.mocked(fetchClanExists).mockResolvedValue(null); + + const input = new ClanTagInput(); + (input as any).clanTag = "ABC"; + (input as any).validate(); + + vi.advanceTimersByTime(401); + await flushPromises(); + await flushPromises(); + + expect(input.isValid()).toBe(true); + }); + + it("discards stale async results when the tag has changed", async () => { + let resolveFirst!: (v: boolean | null) => void; + let resolveSecond!: (v: boolean | null) => void; + const first = new Promise((r) => (resolveFirst = r)); + const second = new Promise((r) => (resolveSecond = r)); + + vi.mocked(getUserMe).mockResolvedValue(false); + vi.mocked(fetchClanExists) + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + + const input = new ClanTagInput(); + + (input as any).clanTag = "AAA"; + (input as any).validate(); + vi.advanceTimersByTime(401); + await flushPromises(); + + // Now the user switches to a different tag before the first response lands. + (input as any).clanTag = "BBB"; + (input as any).validate(); + vi.advanceTimersByTime(401); + await flushPromises(); + + // First (stale) response would have said "AAA exists" → conflict, but the + // tag is no longer AAA, so this must NOT clobber the result for BBB. + resolveFirst(true); + await flushPromises(); + expect((input as any).ownershipError).toBe(""); + + // Second response says BBB doesn't exist → fictional, accept. + resolveSecond(false); + await flushPromises(); + await flushPromises(); + + expect(input.isValid()).toBe(true); + expect(localStorage.getItem("clanTag")).toBe("BBB"); + }); + + it("clears the pending timer in disconnectedCallback", () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.mocked(fetchClanExists).mockResolvedValue(false); + + const input = new ClanTagInput(); + (input as any).clanTag = "ABC"; + (input as any).validate(); + + expect((input as any).checkTimer).not.toBeNull(); + + (input as any).disconnectedCallback(); + + expect((input as any).checkTimer).toBeNull(); + }); + + it("getValue returns null for empty/short/invalid tags and the tag when valid", () => { + const input = new ClanTagInput(); + (input as any).clanTag = ""; + expect(input.getValue()).toBeNull(); + (input as any).clanTag = "A"; + expect(input.getValue()).toBeNull(); + (input as any).clanTag = "TOOLONG"; + expect(input.getValue()).toBeNull(); + (input as any).clanTag = "ABC"; + expect(input.getValue()).toBe("ABC"); + }); +}); diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index f55b3fb90..4065fd75d 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -10,6 +10,7 @@ vi.mock("../../../src/client/Auth", () => ({ import { fetchClanDetail, + fetchClanExists, fetchClanGames, fetchClanLeaderboard, fetchClanMembers, @@ -72,6 +73,66 @@ describe("fetchClanLeaderboard", () => { }); }); +describe("fetchClanExists", () => { + const okStatus = (status: number, body: string = "") => ({ + status, + text: async () => body, + }); + + it("returns true on HTTP 200", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(okStatus(200))), + ); + await expect(fetchClanExists("ABC")).resolves.toBe(true); + }); + + it("returns false on HTTP 404", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(okStatus(404))), + ); + await expect(fetchClanExists("XYZ")).resolves.toBe(false); + }); + + it("returns null on unexpected status (5xx)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(okStatus(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 the tag in the URL", async () => { + const fetchSpy = vi.fn( + (_input: string | URL | Request, _init?: RequestInit) => + Promise.resolve(okStatus(200)), + ); + vi.stubGlobal("fetch", fetchSpy); + await fetchClanExists("abc"); + const calledUrl = fetchSpy.mock.calls[0]![0] as string; + expect(calledUrl).toContain("/public/clan/ABC/exists"); + }); + + it("treats a body {exists:false} as false on 200", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(okStatus(200, JSON.stringify({ exists: false }))), + ), + ); + await expect(fetchClanExists("ABC")).resolves.toBe(false); + }); +}); + describe("fetchClanDetail", () => { const clanInfo = { name: "Test Clan", diff --git a/tests/server/GameLifecycle.test.ts b/tests/server/GameLifecycle.test.ts index 19661a764..7618fb658 100644 --- a/tests/server/GameLifecycle.test.ts +++ b/tests/server/GameLifecycle.test.ts @@ -14,6 +14,7 @@ vi.mock("../../src/core/Schemas", async () => { }); import { GameType } from "../../src/core/game/Game"; +import { Client } from "../../src/server/Client"; import { GameServer } from "../../src/server/GameServer"; describe("GameLifecycle", () => { @@ -86,3 +87,114 @@ describe("GameLifecycle", () => { expect((game as any)._hasEnded).toBe(true); }); }); + +describe("GameServer.rejoinClient — clanTag identityUpdate", () => { + let mockLogger: any; + const mkWs = (): any => ({ + readyState: 1, // OPEN + on: vi.fn(), + send: vi.fn(), + close: vi.fn(), + removeAllListeners: vi.fn(), + }); + + beforeEach(() => { + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const seedClient = (game: GameServer, clanTag: string | null) => { + const ws = mkWs(); + const client = new Client( + "cid-1", + "pid-1", + null, + null, + undefined, + "127.0.0.1", + "tester", + clanTag, + ws, + undefined, + undefined, + [], + ); + // Seed internals as if the client had joined normally. + (game as any).activeClients.push(client); + (game as any).allClients.set(client.clientID, client); + (game as any).persistentIdToClientId.set( + client.persistentID, + client.clientID, + ); + (game as any).websockets.add(ws); + return client; + }; + + it("preserves clanTag on reconnect when identityUpdate omits it", () => { + const game = new GameServer("g-1", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); + const client = seedClient(game, "ABC"); + + const newWs = mkWs(); + const ok = game.rejoinClient(newWs as any, "pid-1", 0, { + username: "renamed", + }); + + expect(ok).toBe(true); + expect(client.clanTag).toBe("ABC"); + expect(client.username).toBe("renamed"); + }); + + it("clears clanTag on reconnect when identityUpdate passes null", () => { + const game = new GameServer("g-2", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); + const client = seedClient(game, "ABC"); + + game.rejoinClient(mkWs() as any, "pid-1", 0, { + username: "tester", + clanTag: null, + }); + + expect(client.clanTag).toBeNull(); + }); + + it("updates clanTag on reconnect when identityUpdate passes a new tag", () => { + const game = new GameServer("g-3", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); + const client = seedClient(game, "ABC"); + + game.rejoinClient(mkWs() as any, "pid-1", 0, { + username: "tester", + clanTag: "XYZ", + }); + + expect(client.clanTag).toBe("XYZ"); + }); + + it("does not change identity if the game has already started", () => { + const game = new GameServer("g-4", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); + const client = seedClient(game, "ABC"); + (game as any)._hasStarted = true; + + game.rejoinClient(mkWs() as any, "pid-1", 0, { + username: "renamed", + clanTag: "XYZ", + }); + + expect(client.clanTag).toBe("ABC"); + expect(client.username).toBe("tester"); + }); +}); diff --git a/tests/server/JwtClanExists.test.ts b/tests/server/JwtClanExists.test.ts new file mode 100644 index 000000000..b0da43fc5 --- /dev/null +++ b/tests/server/JwtClanExists.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/server/ServerEnv", () => ({ + ServerEnv: { + jwtIssuer: () => "http://auth.test", + apiKey: () => "test-key", + }, +})); + +vi.mock("../../src/server/Logger", () => ({ + logger: { + child: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { + _clearClanExistsCacheForTest, + clanExistsByTag, +} from "../../src/server/jwt"; + +const jsonResponse = (status: number, body: unknown = "") => ({ + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), +}); + +beforeEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + _clearClanExistsCacheForTest(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("clanExistsByTag", () => { + it("returns true on HTTP 200", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse(200))), + ); + await expect(clanExistsByTag("ABC")).resolves.toBe(true); + }); + + it("returns false on HTTP 404", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse(404))), + ); + await expect(clanExistsByTag("XYZ")).resolves.toBe(false); + }); + + it("returns null and fails open on unexpected status (5xx)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse(503))), + ); + await expect(clanExistsByTag("ABC")).resolves.toBeNull(); + }); + + it("returns null and fails open on rate-limit (429)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse(429))), + ); + await expect(clanExistsByTag("ABC")).resolves.toBeNull(); + }); + + it("returns null on transport error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + await expect(clanExistsByTag("ABC")).resolves.toBeNull(); + }); + + it("caches results across calls within TTL", async () => { + const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200))); + vi.stubGlobal("fetch", fetchSpy); + await clanExistsByTag("ABC"); + await clanExistsByTag("ABC"); + await clanExistsByTag("ABC"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("does not cache fail-open (null) results so transient outages recover", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(jsonResponse(503)) + .mockResolvedValueOnce(jsonResponse(200)); + vi.stubGlobal("fetch", fetchSpy); + await expect(clanExistsByTag("ABC")).resolves.toBeNull(); + await expect(clanExistsByTag("ABC")).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("uppercases the tag in the URL", async () => { + const fetchSpy = vi.fn( + (_input: string | URL | Request, _init?: RequestInit) => + Promise.resolve(jsonResponse(200)), + ); + vi.stubGlobal("fetch", fetchSpy); + await clanExistsByTag("abc"); + const calledUrl = fetchSpy.mock.calls[0]![0] as string; + expect(calledUrl).toContain("/public/clan/ABC/exists"); + }); + + it("treats a body {exists:false} as false on 200 (forward-compat)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse(200, { exists: false }))), + ); + await expect(clanExistsByTag("ABC")).resolves.toBe(false); + }); + + it("caches by uppercased tag (different cases hit the same entry)", async () => { + const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200))); + vi.stubGlobal("fetch", fetchSpy); + await clanExistsByTag("abc"); + await clanExistsByTag("ABC"); + await clanExistsByTag("Abc"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("sends Accept: application/json header", async () => { + const fetchSpy = vi.fn( + (_input: string | URL | Request, _init?: RequestInit) => + Promise.resolve(jsonResponse(200)), + ); + vi.stubGlobal("fetch", fetchSpy); + await clanExistsByTag("ABC"); + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect((init.headers as Record).Accept).toBe( + "application/json", + ); + }); +});