mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 23:43:34 +00:00
update
This commit is contained in:
+25
-7
@@ -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<ClanInfo | false> {
|
||||
|
||||
// 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<boolean | null> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, string>;
|
||||
defaultTranslations?: Record<string, string>;
|
||||
}
|
||||
|
||||
@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<typeof setTimeout> | 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<LangSelectorLike & Element>(
|
||||
"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`
|
||||
<div class="relative flex items-center h-full">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.clanTag}
|
||||
@input=${this.handleInput}
|
||||
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"
|
||||
/>
|
||||
${this.validationError
|
||||
? 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"
|
||||
>
|
||||
${this.validationError}
|
||||
</div>`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+20
-1
@@ -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<JoinLobbyEvent>) {
|
||||
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");
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
|
||||
+16
-172
@@ -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<string, string>;
|
||||
@@ -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<typeof setTimeout> | 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`
|
||||
<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 w-full h-full">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.baseUsername}
|
||||
@@ -123,52 +88,26 @@ export class UsernameInput extends LitElement {
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
minlength="${MIN_USERNAME_LENGTH}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
class="w-full h-full border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
/>
|
||||
${this.validationError
|
||||
? html`<div
|
||||
id="username-validation-error"
|
||||
class="absolute top-full left-0 z-50 w-full 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"
|
||||
>
|
||||
${this.validationError}
|
||||
</div>`
|
||||
: null}
|
||||
</div>
|
||||
${this.validationError
|
||||
? html`<div
|
||||
id="username-validation-error"
|
||||
class="absolute top-full left-0 z-50 w-full 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"
|
||||
>
|
||||
${this.validationError}
|
||||
</div>`
|
||||
: 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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<clan-tag-input
|
||||
class="shrink-0 h-10 lg:h-[50px]"
|
||||
></clan-tag-input>
|
||||
<username-input
|
||||
class="flex-1 min-w-0 h-10 lg:h-[50px]"
|
||||
></username-input>
|
||||
|
||||
@@ -21,6 +21,25 @@ export const RefreshResponseSchema = z.object({
|
||||
});
|
||||
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
|
||||
|
||||
// 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<typeof ClanExistsResponseSchema>;
|
||||
|
||||
export const TokenPayloadSchema = z.object({
|
||||
jti: z.string(),
|
||||
sub: z
|
||||
|
||||
+80
-8
@@ -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<boolean | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function createMockLocalStorage(): Storage {
|
||||
let store: Record<string, string> = {};
|
||||
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<boolean | null>((r) => (resolveFirst = r));
|
||||
const second = new Promise<boolean | null>((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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>).Accept).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user