diff --git a/resources/lang/en.json b/resources/lang/en.json index 13e735459..1a6c76367 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -662,7 +662,8 @@ "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_not_member": "Join the {tag} clan before using its tag." + "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 36a8ab104..fd044dfbb 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -1,7 +1,4 @@ -import { - ClanExistsResponseSchema, - clanExistsApiPath, -} from "../core/ApiSchemas"; +import { clanExistsApiPath } from "../core/ApiSchemas"; import { type ClanBansResponse, ClanBansResponseSchema, @@ -36,9 +33,9 @@ export type { ClanInfo, ClanJoinRequest, ClanMember, + ClanMembersResponse, ClanMemberStats, ClanMemberWL, - ClanMembersResponse, ClanRequestsResponse, } from "../core/ClanApiSchemas"; @@ -131,28 +128,15 @@ 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, timeouts, or transport errors so callers -// can fail open. +// 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) { - 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 === 200) return true; if (res.status === 404) return false; return null; } catch { diff --git a/src/client/ClanTagInput.ts b/src/client/ClanTagInput.ts index 999364693..cf77d0695 100644 --- a/src/client/ClanTagInput.ts +++ b/src/client/ClanTagInput.ts @@ -7,7 +7,6 @@ import { } from "../core/validations/username"; import { IdentityReadyController } from "./identity/IdentityReadyController"; import { - awaitIdentityReady, getClanTagForSubmit, initIdentityFromStorage, revalidateIdentityTranslations, @@ -94,9 +93,9 @@ export class ClanTagInput extends LitElement { private translatedError(raw: string): string { if (!raw) return ""; - // Ownership errors are stored as i18n keys (with optional tag param); - // format errors are already-translated strings from validateClanTag. - if (raw === "username.tag_not_member") { + // Ownership errors are stored as i18n keys; format errors are already + // translated strings from validateClanTag. + if (raw.startsWith("username.")) { return translateText(raw, { tag: this.identity.state.clanTag.value }); } return raw; @@ -122,12 +121,6 @@ export class ClanTagInput extends LitElement { input.value = sanitized; } - // Resolves once any in-flight async ownership check settles. Returns - // immediately when nothing is in flight. - public async awaitValidation(): Promise { - await awaitIdentityReady(); - } - public showValidationFeedback() { const message = this.translatedError(this.identity.state.clanTag.error) || diff --git a/src/client/Main.ts b/src/client/Main.ts index 2cdd2c2c1..3e4aff244 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -812,10 +812,9 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; this.mostRecentJoinEvent = event.timeStamp; - // Final identity gate. Callers (play buttons, modals) already disable - // themselves until identity is ready, so this is a defense-in-depth check - // for stray dispatches — bail silently rather than show a toast since - // the inputs already render their own inline errors. + // Wait for any in-flight clan-tag check, then gate: buttons disable + // themselves until ready, and this also covers programmatic/deep-link + // joins. Bail silently — the inputs render their own inline errors. const ready = await awaitIdentityReady(); if (!ready) { console.warn("join-lobby blocked: identity not ready"); diff --git a/src/client/identity/IdentityReadyController.ts b/src/client/identity/IdentityReadyController.ts index 06007bce1..7ac1eddfd 100644 --- a/src/client/identity/IdentityReadyController.ts +++ b/src/client/identity/IdentityReadyController.ts @@ -1,14 +1,12 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; import { - awaitIdentityReady, getIdentityState, subscribeIdentity, type IdentityState, } from "./IdentityStore"; -// Subscribes a Lit host to the identity store and triggers a re-render -// whenever the ready/validating state changes. Lets play buttons bind -// `?disabled=${!identity.ready}` without bespoke wiring per consumer. +// Subscribes a Lit host to the identity store and re-renders it whenever the +// ready state changes, so play buttons can bind `?disabled=${!identity.ready}`. export class IdentityReadyController implements ReactiveController { private unsubscribe: (() => void) | null = null; private snapshot: IdentityState; @@ -41,8 +39,4 @@ export class IdentityReadyController implements ReactiveController { get state(): IdentityState { return this.snapshot; } - - async awaitReady(): Promise { - return awaitIdentityReady(); - } } diff --git a/src/client/identity/IdentityStore.ts b/src/client/identity/IdentityStore.ts index 1a7cb1910..ab1d6ee8f 100644 --- a/src/client/identity/IdentityStore.ts +++ b/src/client/identity/IdentityStore.ts @@ -1,7 +1,5 @@ import { sanitizeClanTag } from "../../core/Util"; import { - MAX_CLAN_TAG_LENGTH, - MIN_CLAN_TAG_LENGTH, validateClanTag, validateUsername, } from "../../core/validations/username"; @@ -36,18 +34,13 @@ const state: IdentityState = { ready: false, }; -let lastInput: { username: string; clanTag: string } = { - username: "", - clanTag: "", -}; - -function recomputeReady() { - state.ready = - state.username.valid && state.clanTag.valid && !state.clanTagChecking; -} +let lastInput = { username: "", clanTag: "" }; function emit() { - recomputeReady(); + // Play is gated until the username is valid AND the clan tag is proven OK + // (owned or fictional). While a check is in flight, nothing is proven yet. + state.ready = + state.username.valid && state.clanTag.valid && !state.clanTagChecking; for (const listener of listeners) listener(state); } @@ -67,19 +60,10 @@ export function getUsernameForSubmit(): string { return state.username.value; } -// Mirrors the legacy ClanTagInput.getValue contract: only emit a non-null -// value when the tag is valid AND meets length AND format. Empty / pending / -// failed states submit as null so the server falls back to "no tag". export function getClanTagForSubmit(): string | null { - // Don't submit a tag while the ownership check is in flight — callers - // either gate on `state.ready` first or await `awaitIdentityReady()`. if (state.clanTagChecking) return null; const { value, valid } = state.clanTag; - if (!valid) return null; - if (value.length < MIN_CLAN_TAG_LENGTH) return null; - if (value.length > MAX_CLAN_TAG_LENGTH) return null; - if (!validateClanTag(value).isValid) return null; - return value; + return valid && value.length > 0 ? value : null; } export function setUsername(raw: string) { @@ -91,9 +75,7 @@ export function setUsername(raw: string) { valid: result.isValid, error: result.isValid ? "" : (result.error ?? ""), }; - if (result.isValid) { - localStorage.setItem(USERNAME_KEY, trimmed); - } + if (result.isValid) localStorage.setItem(USERNAME_KEY, trimmed); emit(); } @@ -107,12 +89,12 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) { const tag = sanitizeClanTag(raw); const result = validateClanTag(tag); - // Cancel any pending/in-flight ownership work. checkCounter++ marks stale - // chains; resolving the prior debounce lets awaitReady() callers unblock. + // A new value supersedes any pending/in-flight check and unblocks + // awaitIdentityReady() callers waiting on the prior one. if (clanCheckTimer !== null) clearTimeout(clanCheckTimer); clanCheckTimer = null; clanCheckCounter++; - if (resolveDebounce) resolveDebounce(); + resolveDebounce?.(); resolveDebounce = null; state.clanTag = { @@ -122,8 +104,6 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) { }; if (!result.isValid || tag.length === 0) { - // Nothing to ask the server about. Wipe the stored tag so a reload - // doesn't restore a stale value that no longer matches input. state.clanTagChecking = false; localStorage.setItem(CLAN_TAG_KEY, ""); currentCheck = Promise.resolve(); @@ -131,15 +111,15 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) { return; } + // Well-formed tag: nothing is proven until the ownership check resolves. state.clanTagChecking = true; emit(); const generation = clanCheckCounter; - const run = (): Promise => { - if (generation !== clanCheckCounter) return Promise.resolve(); - return runOwnershipCheck(tag, generation); - }; - + const run = () => + generation === clanCheckCounter + ? runOwnershipCheck(tag, generation) + : Promise.resolve(); if (options.immediate) { currentCheck = run(); } else { @@ -156,6 +136,9 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) { } } +// Members are always accepted. A non-member keeps a tag only if the clan is +// fictional; a real clan they don't belong to, or anything we can't verify, +// is rejected so play stays gated until the tag is proven. async function runOwnershipCheck(tag: string, generation: number) { const stillCurrent = () => generation === clanCheckCounter && state.clanTag.value === tag; @@ -165,16 +148,16 @@ async function runOwnershipCheck(tag: string, generation: number) { 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 !== false) { - rejectTag(tag); - return; - } + if (myTags.includes(tag.toUpperCase())) { + acceptTag(tag); + return; } - acceptTag(tag); + + const exists = await fetchClanExists(tag); + if (!stillCurrent()) return; + if (exists === false) acceptTag(tag); + else if (exists === true) rejectTag(tag, "username.tag_not_member"); + else rejectTag(tag, "username.tag_check_failed"); } function acceptTag(tag: string) { @@ -184,19 +167,14 @@ function acceptTag(tag: string) { emit(); } -function rejectTag(tag: string) { - state.clanTag = { - value: tag, - valid: false, - error: "username.tag_not_member", - }; +function rejectTag(tag: string, error: string) { + state.clanTag = { value: tag, valid: false, error }; state.clanTagChecking = false; localStorage.removeItem(CLAN_TAG_KEY); emit(); } -// Resolves once any in-flight async clan check settles. Returns the final -// ready state so callers can branch without a second read. +// Resolves once any in-flight clan check settles; returns the final ready state. export async function awaitIdentityReady(): Promise { let last: Promise | undefined; while (currentCheck !== last) { @@ -206,28 +184,21 @@ export async function awaitIdentityReady(): Promise { return state.ready; } -// Re-runs sync validation against the last raw input so error messages get -// re-translated when the active language changes. Does NOT re-trigger the -// async ownership check (the cached result is still correct). +// Re-runs sync validation against the last raw input so error strings get +// re-translated on a language change. A confirmed ownership error (i18n key, +// format-valid) is preserved. export function revalidateIdentityTranslations() { const trimmed = lastInput.username.trim(); - const usernameResult = validateUsername(trimmed); + const u = validateUsername(trimmed); state.username = { value: trimmed, - valid: usernameResult.isValid, - error: usernameResult.isValid ? "" : (usernameResult.error ?? ""), + valid: u.isValid, + error: u.isValid ? "" : (u.error ?? ""), }; - const tag = sanitizeClanTag(lastInput.clanTag); - const tagResult = validateClanTag(tag); - // Preserve any existing ownership error (it's already an i18n key); only - // refresh the format-level error so language changes pick up new strings. - if (!tagResult.isValid) { - state.clanTag = { - value: tag, - valid: false, - error: tagResult.error ?? "", - }; + const t = validateClanTag(tag); + if (!t.isValid) { + state.clanTag = { value: tag, valid: false, error: t.error ?? "" }; } emit(); } @@ -236,18 +207,11 @@ let initialized = false; export function initIdentityFromStorage() { if (initialized) return; initialized = true; - const storedUsername = localStorage.getItem(USERNAME_KEY) ?? ""; - setUsername(storedUsername); - const storedClanTag = localStorage.getItem(CLAN_TAG_KEY) ?? ""; - if (storedClanTag.length > 0) { - setClanTag(storedClanTag, { immediate: true }); - } else { - setClanTag("", { immediate: true }); - } + setUsername(localStorage.getItem(USERNAME_KEY) ?? ""); + setClanTag(localStorage.getItem(CLAN_TAG_KEY) ?? "", { immediate: true }); } -// Test-only reset; not part of the public surface but exported so unit tests -// can wipe singleton state between cases. +// Test-only reset for the singleton module state. export function __resetIdentityStoreForTests() { initialized = false; listeners.clear(); diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 03f665198..f60ad22c5 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -21,25 +21,12 @@ 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. +// 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`; } -// 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/ClanTagOwnership.ts b/src/server/ClanTagOwnership.ts index 7f6d6f756..55cc802a4 100644 --- a/src/server/ClanTagOwnership.ts +++ b/src/server/ClanTagOwnership.ts @@ -1,129 +1,41 @@ -import { - ClanExistsResponseSchema, - clanExistsApiPath, - type UserMeResponse, -} from "../core/ApiSchemas"; - -// Clan-existence probe used by the join-time ownership check. -// -// Only positive results are cached: a fictional tag (clan does not exist -// upstream) could legitimately become real moments later, and we don't want a -// stale "false" to leak a tag past the ownership check until TTL expiry. -// Positive results are safe to cache because a real clan stays real for the -// life of any reasonable cache entry, and a cache hit returns "exists=true", -// which only ever causes the tag to be dropped (fail-closed). +import { clanExistsApiPath, type UserMeResponse } from "../core/ApiSchemas"; export const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; -export const CLAN_EXISTS_CACHE_TTL_MS = 60_000; -export const CLAN_EXISTS_CACHE_MAX_ENTRIES = 1024; - -interface CacheEntry { - expiresAt: number; -} - -// Insertion-ordered Map gives us a trivial FIFO/LRU: re-set on hit to bump -// freshness, evict from the oldest end when the bound is reached. -const positiveCache = new Map(); interface ProbeDeps { /** Base URL of the upstream auth API (issuer). */ baseUrl: string; /** Injected so tests can stub network behavior. */ fetcher?: typeof fetch; - /** Injected so tests control time. */ - now?: () => number; /** Logger callback for unexpected statuses / transport errors. */ onWarn?: (event: string, ctx: Record) => void; - /** Override the shared cache (tests). */ - cache?: Map; - /** Override TTL / bound (tests). */ - ttlMs?: number; - maxEntries?: number; -} - -function getCachedExists( - cache: Map, - key: string, - nowMs: number, -): boolean | undefined { - const entry = cache.get(key); - if (entry === undefined) return undefined; - if (nowMs >= entry.expiresAt) { - cache.delete(key); - return undefined; - } - // Bump LRU recency. - cache.delete(key); - cache.set(key, entry); - return true; -} - -function setCachedExists( - cache: Map, - key: string, - expiresAt: number, - maxEntries: number, -): void { - cache.set(key, { expiresAt }); - while (cache.size > maxEntries) { - const oldest = cache.keys().next(); - if (oldest.done) break; - cache.delete(oldest.value); - } } /** * 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 should treat null as fail-closed (drop the tag). + * unexpected status). Callers treat null as fail-closed (drop the tag). */ export async function clanExistsByTag( tag: string, deps: ProbeDeps, ): Promise { - const cache = deps.cache ?? positiveCache; - const now = deps.now ?? Date.now; const fetcher = deps.fetcher ?? fetch; - const ttlMs = deps.ttlMs ?? CLAN_EXISTS_CACHE_TTL_MS; - const maxEntries = deps.maxEntries ?? CLAN_EXISTS_CACHE_MAX_ENTRIES; - - const cacheKey = tag.toUpperCase(); - const cached = getCachedExists(cache, cacheKey, now()); - if (cached === true) return true; - try { - const url = `${deps.baseUrl}${clanExistsApiPath(tag)}`; - const response = await fetcher(url, { + const response = await fetcher(`${deps.baseUrl}${clanExistsApiPath(tag)}`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), }); - if (response.status === 200) { - // Upstream currently has no body; tolerate {exists:false} for forward-compat. - try { - const text = await response.text(); - if (text.length > 0) { - const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); - if (parsed.success && parsed.data?.exists === false) { - return false; - } - } - } catch { - // Forward-compat parsing only; ignore failures. - } - setCachedExists(cache, cacheKey, now() + ttlMs, maxEntries); - return true; - } - if (response.status === 404) { - return false; - } + if (response.status === 200) return true; + if (response.status === 404) return false; deps.onWarn?.("clanExistsByTag: unexpected status, failing closed", { - tag: cacheKey, + tag: tag.toUpperCase(), status: response.status, }); return null; } catch (e) { deps.onWarn?.("clanExistsByTag: fetch failed, failing closed", { - tag: cacheKey, + tag: tag.toUpperCase(), error: e instanceof Error ? e.message : String(e), }); return null; @@ -133,13 +45,11 @@ export async function clanExistsByTag( /** * Decide whether a player may wear the given (already-censored) clan tag. * - * - Members of the tag's clan always pass through unchanged. - * - Non-members keep the tag only when the upstream API confirms the clan - * does not exist (fictional tag). - * - Inconclusive existence results drop the tag (fail-closed). - * - * @returns the resolved tag (the original or null) along with a `dropped` flag - * so callers can log impersonation attempts. + * - 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, @@ -162,22 +72,10 @@ export async function resolveClanTag( } const exists = await existsChecker(censoredTag); - if (exists === false) { - return { tag: censoredTag, dropped: false }; - } + if (exists === false) return { tag: censoredTag, dropped: false }; return { tag: null, dropped: true, reason: exists === true ? "exists" : "inconclusive", }; } - -/** Exposed for tests so each spec starts with a clean cache. */ -export function __resetClanExistsCacheForTests(): void { - positiveCache.clear(); -} - -/** Exposed for tests/observability. */ -export function __peekClanExistsCacheSize(): number { - return positiveCache.size; -} diff --git a/tests/client/IdentityStore.test.ts b/tests/client/IdentityStore.test.ts index 90e419c23..ff2794acf 100644 --- a/tests/client/IdentityStore.test.ts +++ b/tests/client/IdentityStore.test.ts @@ -39,11 +39,21 @@ import { } from "../../src/client/identity/IdentityStore"; const flushPromises = async () => { - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } + for (let i = 0; i < 5; i++) await Promise.resolve(); }; +const anonMe = { + user: {}, + player: { + publicId: "p1", + adfree: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + clans: [], + }, +} as any; + beforeEach(() => { vi.useFakeTimers(); vi.mocked(getUserMe).mockReset(); @@ -59,18 +69,8 @@ afterEach(() => { }); describe("IdentityStore clan-tag 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); + it("rejects with tag_not_member when not a member and the clan exists", async () => { + vi.mocked(getUserMe).mockResolvedValue(anonMe); vi.mocked(fetchClanExists).mockResolvedValue(true); setClanTag("ABC"); @@ -83,18 +83,8 @@ describe("IdentityStore clan-tag ownership check", () => { expect(state.clanTag.error).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); + it("clears any stored clanTag on an ownership conflict", async () => { + vi.mocked(getUserMe).mockResolvedValue(anonMe); vi.mocked(fetchClanExists).mockResolvedValue(true); localStorage.setItem("clanTag", "ABC"); @@ -106,7 +96,7 @@ describe("IdentityStore clan-tag ownership check", () => { expect(localStorage.getItem("clanTag")).toBeNull(); }); - it("keeps the tag when the clan does not exist (fictional)", async () => { + it("accepts a fictional tag (clan does not exist)", async () => { vi.mocked(getUserMe).mockResolvedValue(false); vi.mocked(fetchClanExists).mockResolvedValue(false); @@ -119,7 +109,22 @@ describe("IdentityStore clan-tag ownership check", () => { expect(localStorage.getItem("clanTag")).toBe("FIC"); }); - it("fails closed: rejects the tag when existence check is inconclusive", async () => { + it("accepts a member's tag without probing existence", async () => { + vi.mocked(getUserMe).mockResolvedValue({ + ...anonMe, + player: { ...anonMe.player, clans: [{ tag: "ABC" }] }, + }); + + setClanTag("ABC"); + vi.advanceTimersByTime(401); + await flushPromises(); + await flushPromises(); + + expect(getIdentityState().clanTag.valid).toBe(true); + expect(fetchClanExists).not.toHaveBeenCalled(); + }); + + it("rejects with tag_check_failed when existence is inconclusive", async () => { vi.mocked(getUserMe).mockResolvedValue(false); vi.mocked(fetchClanExists).mockResolvedValue(null); @@ -128,7 +133,10 @@ describe("IdentityStore clan-tag ownership check", () => { await flushPromises(); await flushPromises(); - expect(getIdentityState().clanTag.valid).toBe(false); + // Can't prove ownership -> stays gated, with a message telling the user. + const state = getIdentityState(); + expect(state.clanTag.valid).toBe(false); + expect(state.clanTag.error).toBe("username.tag_check_failed"); }); it("discards stale async results when the tag has changed", async () => { @@ -146,18 +154,16 @@ describe("IdentityStore clan-tag ownership check", () => { vi.advanceTimersByTime(401); await flushPromises(); - // User switches to a different tag before the first response lands. + // Switch tags before the first probe lands. setClanTag("BBB"); 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. + // Stale "AAA exists" must not clobber BBB. resolveFirst(true); await flushPromises(); expect(getIdentityState().clanTag.error).toBe(""); - // Second response says BBB doesn't exist → fictional, accept. resolveSecond(false); await flushPromises(); await flushPromises(); @@ -166,13 +172,12 @@ describe("IdentityStore clan-tag ownership check", () => { expect(localStorage.getItem("clanTag")).toBe("BBB"); }); - it("flips ready false while a check is in flight, true on success", async () => { + it("gates play while a check is in flight, then clears it", async () => { vi.mocked(getUserMe).mockResolvedValue(false); vi.mocked(fetchClanExists).mockResolvedValue(false); setClanTag("ABC"); - // Pre-debounce: checking flag already set so play buttons disable - // immediately, not after the debounce. + // Pre-debounce: checking is already set so buttons disable immediately. expect(getIdentityState().clanTagChecking).toBe(true); expect(getIdentityState().ready).toBe(false); @@ -183,18 +188,17 @@ describe("IdentityStore clan-tag ownership check", () => { expect(getIdentityState().clanTag.valid).toBe(true); }); - it("getClanTagForSubmit returns null while empty/short/pending; tag once accepted", async () => { + it("getClanTagForSubmit: null while empty/short/checking; tag once accepted", async () => { vi.mocked(getUserMe).mockResolvedValue(false); vi.mocked(fetchClanExists).mockResolvedValue(false); setClanTag(""); expect(getClanTagForSubmit()).toBeNull(); - setClanTag("A"); + setClanTag("A"); // too short -> format invalid expect(getClanTagForSubmit()).toBeNull(); setClanTag("ABC"); - // Ownership check hasn't resolved yet → not submittable. expect(getIdentityState().clanTagChecking).toBe(true); expect(getClanTagForSubmit()).toBeNull(); diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index 4065fd75d..6866904fd 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -121,16 +121,6 @@ describe("fetchClanExists", () => { 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", () => { 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"); + }); +}); diff --git a/tests/server/ClanTagOwnership.test.ts b/tests/server/ClanTagOwnership.test.ts index 4443694b5..5820526c5 100644 --- a/tests/server/ClanTagOwnership.test.ts +++ b/tests/server/ClanTagOwnership.test.ts @@ -1,15 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { UserMeResponse } from "../../src/core/ApiSchemas"; import { clanExistsByTag, resolveClanTag, } from "../../src/server/ClanTagOwnership"; -const okResponse = (status: number, body = ""): Response => - ({ - status, - text: async () => body, - }) as unknown as Response; +const okResponse = (status: number): Response => + ({ status }) as unknown as Response; const userWithClans = (tags: string[]): UserMeResponse => ({ @@ -32,145 +29,51 @@ const userWithClans = (tags: string[]): UserMeResponse => }) as UserMeResponse; describe("clanExistsByTag", () => { - let cache: Map; - let now: number; - - beforeEach(() => { - cache = new Map(); - now = 1_000_000; + const deps = (fetcher: () => Promise) => ({ + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, }); it("returns true on HTTP 200", async () => { - const fetcher = vi.fn(async () => okResponse(200)); - const result = await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); + const result = await clanExistsByTag( + "ABC", + deps(async () => okResponse(200)), + ); expect(result).toBe(true); }); - it("returns false on HTTP 404 without caching it", async () => { - const fetcher = vi.fn(async () => okResponse(404)); - const result = await clanExistsByTag("XYZ", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); + it("returns false on HTTP 404", async () => { + const result = await clanExistsByTag( + "XYZ", + deps(async () => okResponse(404)), + ); expect(result).toBe(false); - // Negative results must not poison the cache — a clan can be created - // moments after a 404 and a stale "false" would briefly let non-members - // wear the tag. - expect(cache.size).toBe(0); }); - it("returns null on unexpected status (fail-closed) and does not cache", async () => { - const fetcher = vi.fn(async () => okResponse(503)); - const result = await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); + it("returns null on unexpected status (fail-closed)", async () => { + const result = await clanExistsByTag( + "ABC", + deps(async () => okResponse(503)), + ); expect(result).toBeNull(); - expect(cache.size).toBe(0); }); it("returns null on transport error (fail-closed)", async () => { - const fetcher = vi.fn(async () => { - throw new Error("offline"); - }); - const result = await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); + 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", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); + await clanExistsByTag("abc", deps(fetcher)); const calledUrl = (fetcher.mock.calls[0] as unknown[])[0] as string; expect(calledUrl).toContain("/public/clan/ABC/exists"); }); - - it("serves positive results from cache without re-fetching", async () => { - const fetcher = vi.fn(async () => okResponse(200)); - await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); - await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); - expect(fetcher).toHaveBeenCalledTimes(1); - }); - - it("re-fetches positive entries after TTL expiry", async () => { - const fetcher = vi.fn(async () => okResponse(200)); - await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - ttlMs: 1000, - }); - now += 2000; - await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - ttlMs: 1000, - }); - expect(fetcher).toHaveBeenCalledTimes(2); - }); - - it("evicts the oldest entry when maxEntries is exceeded", async () => { - const fetcher = vi.fn(async () => okResponse(200)); - const deps = { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - maxEntries: 2, - }; - await clanExistsByTag("A", deps); - await clanExistsByTag("B", deps); - await clanExistsByTag("C", deps); - expect(cache.size).toBe(2); - expect(cache.has("A")).toBe(false); - expect(cache.has("B")).toBe(true); - expect(cache.has("C")).toBe(true); - }); - - it("treats body {exists:false} as false on 200 without caching", async () => { - const fetcher = vi.fn(async () => - okResponse(200, JSON.stringify({ exists: false })), - ); - const result = await clanExistsByTag("ABC", { - baseUrl: "https://auth.example", - fetcher: fetcher as unknown as typeof fetch, - cache, - now: () => now, - }); - expect(result).toBe(false); - expect(cache.size).toBe(0); - }); }); describe("resolveClanTag", () => {