mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
init
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+69
-10
@@ -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,15 +137,25 @@ export class UsernameInput extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex items-center w-full h-full gap-2">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.clanTag}
|
||||
@input=${this.handleClanTagChange}
|
||||
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"
|
||||
/>
|
||||
<div class="relative flex items-center shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.clanTag}
|
||||
@input=${this.handleClanTagChange}
|
||||
placeholder="${translateText("username.tag")}"
|
||||
minlength="${MIN_CLAN_TAG_LENGTH}"
|
||||
maxlength="${MAX_CLAN_TAG_LENGTH}"
|
||||
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,7 +173,16 @@ export class UsernameInput extends LitElement {
|
||||
>
|
||||
${this.validationError}
|
||||
</div>`
|
||||
: null}
|
||||
: 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+59
-32
@@ -7,6 +7,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { type UserMeResponse } from "../core/ApiSchemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import { logger } from "./Logger";
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { setNoStoreHeaders } from "./NoStoreHeaders";
|
||||
import { startPolling } from "./PollingLoop";
|
||||
import { clanExistsByTag, resolveClanTag } from "./Privilege";
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
import { ServerEnv } from "./ServerEnv";
|
||||
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
|
||||
@@ -357,29 +359,17 @@ export async function startWorker() {
|
||||
}
|
||||
|
||||
// Normalize username and clan tag before any rejoin/join handling.
|
||||
// If this connection maps to an existing lobby client, we still want
|
||||
// the latest pre-join identity to be reflected.
|
||||
const { clanTag: censoredClanTag, username: censoredUsername } =
|
||||
privilegeRefresher
|
||||
.get()
|
||||
.censor(clientMsg.username, clientMsg.clanTag ?? null);
|
||||
|
||||
// Try to reconnect an existing client (e.g., page refresh)
|
||||
// If successful, skip all authorization
|
||||
if (
|
||||
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
|
||||
username: censoredUsername,
|
||||
clanTag: censoredClanTag,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let flares: string[] | undefined;
|
||||
let publicId: string | undefined;
|
||||
let friends: string[] = [];
|
||||
|
||||
// Fetch the user profile up front. It's needed here so the clan-tag
|
||||
// ownership check can run *before* the reconnect path below — otherwise
|
||||
// a page refresh would let a player swap to an unvalidated tag — and is
|
||||
// reused for flares/cosmetics on new joins.
|
||||
const allowedFlares = ServerEnv.allowedFlares();
|
||||
let userMeResponse: UserMeResponse | null = null;
|
||||
if (claims === null) {
|
||||
if (allowedFlares !== undefined) {
|
||||
log.warn("Unauthorized: Anonymous user attempted to join game");
|
||||
@@ -397,21 +387,58 @@ export async function startWorker() {
|
||||
ws.close(1002, "Unauthorized: user me fetch failed");
|
||||
return;
|
||||
}
|
||||
flares = result.response.player.flares;
|
||||
publicId = result.response.player.publicId;
|
||||
friends = result.response.player.friends;
|
||||
userMeResponse = result.response;
|
||||
}
|
||||
|
||||
if (allowedFlares !== undefined) {
|
||||
const allowed =
|
||||
allowedFlares.length === 0 ||
|
||||
allowedFlares.some((f) => flares?.includes(f));
|
||||
if (!allowed) {
|
||||
log.warn(
|
||||
"Forbidden: player without an allowed flare attempted to join game",
|
||||
);
|
||||
ws.close(1002, "Forbidden");
|
||||
return;
|
||||
}
|
||||
// Enforce clan tag ownership. A player can wear a tag only if they're a
|
||||
// member; if they aren't and the tag belongs to a real clan, drop it to
|
||||
// prevent impersonation. Fictional tags pass through.
|
||||
const resolution = await resolveClanTag(
|
||||
censoredClanTag,
|
||||
userMeResponse,
|
||||
(tag) =>
|
||||
clanExistsByTag(tag, {
|
||||
baseUrl: ServerEnv.jwtIssuer(),
|
||||
onWarn: (event, ctx) => log.warn(event, ctx),
|
||||
}),
|
||||
);
|
||||
if (resolution.dropped) {
|
||||
log.warn("Dropped clan tag: player is not a member", {
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
clanTag: censoredClanTag,
|
||||
reason: resolution.reason,
|
||||
});
|
||||
}
|
||||
const resolvedClanTag = resolution.tag;
|
||||
|
||||
// Try to reconnect an existing client (e.g., page refresh). Pre-game,
|
||||
// username and clan tag pick up the latest validated values from this
|
||||
// connection.
|
||||
if (
|
||||
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
|
||||
username: censoredUsername,
|
||||
clanTag: resolvedClanTag,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// New client — finish the join checks.
|
||||
const flares = userMeResponse?.player.flares;
|
||||
const publicId = userMeResponse?.player.publicId;
|
||||
const friends = userMeResponse?.player.friends ?? [];
|
||||
|
||||
if (userMeResponse !== null && allowedFlares !== undefined) {
|
||||
const allowed =
|
||||
allowedFlares.length === 0 ||
|
||||
allowedFlares.some((f) => flares?.includes(f));
|
||||
if (!allowed) {
|
||||
log.warn(
|
||||
"Forbidden: player without an allowed flare attempted to join game",
|
||||
);
|
||||
ws.close(1002, "Forbidden");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +490,7 @@ export async function startWorker() {
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
censoredClanTag,
|
||||
resolvedClanTag,
|
||||
ws,
|
||||
cosmeticResult.cosmetics,
|
||||
publicId,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user