mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
clantag part 1 (#4066)
If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #(issue number) ## Description: adds a check to see if you're in a clan or not. if not, checks to see if the clan exists, if it does, warns the user, if it doesn't, lets them use it. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
@@ -669,7 +669,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",
|
||||
|
||||
+43
-1
@@ -16,8 +16,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 +127,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. The tag is
|
||||
// uppercased to the canonical form so it matches the server's route.
|
||||
export async function fetchClanExists(tag: string): Promise<boolean | null> {
|
||||
try {
|
||||
const path = `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`;
|
||||
const res = await fetch(`${getApiBase()}${path}`, {
|
||||
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 (resolveClanTag in
|
||||
* Privilege.ts), for instant inline feedback. Returns the tag to submit (null
|
||||
* if dropped) and an i18n error key. 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"
|
||||
|
||||
@@ -84,6 +84,9 @@ export interface LobbyConfig {
|
||||
cosmetics: PlayerCosmeticRefs;
|
||||
playerName: string;
|
||||
playerClanTag: string | null;
|
||||
// In-flight clan-tag ownership check; resolves to the tag to submit (null if
|
||||
// it failed). Runs parallel to the WS handshake — only the join waits on it.
|
||||
clanTagCheck?: Promise<string | null>;
|
||||
playerRole: string | null;
|
||||
gameID: GameID;
|
||||
turnstileToken: string | null;
|
||||
@@ -121,7 +124,11 @@ export function joinLobby(
|
||||
|
||||
let currentGameRunner: ClientGameRunner | null = null;
|
||||
|
||||
const onconnect = () => {
|
||||
const onconnect = async () => {
|
||||
// Drop the tag if the ownership check failed; the server re-checks anyway.
|
||||
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,
|
||||
|
||||
+68
-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,32 @@ 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;
|
||||
this.clanTagOwnershipError = "";
|
||||
if (tag.length === 0 || !validateClanTag(tag).isValid) {
|
||||
this.clanCheckPending = false;
|
||||
this.clanCheck = Promise.resolve(null);
|
||||
return;
|
||||
}
|
||||
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 +126,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 +136,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 +172,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 +208,7 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
this.clanTag = val;
|
||||
this.validateAndStore();
|
||||
this.startClanCheck();
|
||||
}
|
||||
|
||||
private handleUsernameChange(e: Event) {
|
||||
|
||||
@@ -3,6 +3,12 @@ import { ClanTagSchema } from "./Schemas";
|
||||
|
||||
const RequiredClanTagSchema = ClanTagSchema.unwrap();
|
||||
|
||||
// Response for the game-server endpoint listing every registered clan tag.
|
||||
export const ReservedClanTagsResponseSchema = z.array(z.string());
|
||||
export type ReservedClanTagsResponse = z.infer<
|
||||
typeof ReservedClanTagsResponseSchema
|
||||
>;
|
||||
|
||||
export const ClanLeaderboardEntrySchema = z.object({
|
||||
clanTag: RequiredClanTagSchema,
|
||||
games: z.number(),
|
||||
|
||||
@@ -151,6 +151,33 @@ function censorWithMatcher(
|
||||
return { username: censoredName, clanTag: censoredClanTag };
|
||||
}
|
||||
|
||||
export type ClanTagResolution = {
|
||||
tag: string | null;
|
||||
dropped: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The clan-tag ownership rule, shared by every PrivilegeChecker:
|
||||
* - member of the clan -> keep the tag
|
||||
* - not a member, tag not reserved -> fictional tag, keep it
|
||||
* - otherwise -> drop it (impersonation)
|
||||
* `reservedTags` is every registered tag (uppercase); null means the reserved
|
||||
* list is unavailable (cosmetics infra still loading), in which case an
|
||||
* unverifiable tag counts as reserved and is dropped fail-closed.
|
||||
*/
|
||||
function decideClanTag(
|
||||
censoredTag: string | null,
|
||||
ownedClanTags: string[],
|
||||
reservedTags: Set<string> | null,
|
||||
): ClanTagResolution {
|
||||
if (censoredTag === null) return { tag: null, dropped: false };
|
||||
const tag = censoredTag.toUpperCase();
|
||||
const isMember = ownedClanTags.some((t) => t.toUpperCase() === tag);
|
||||
const isReserved = reservedTags === null || reservedTags.has(tag);
|
||||
if (isMember || !isReserved) return { tag: censoredTag, dropped: false };
|
||||
return { tag: null, dropped: true };
|
||||
}
|
||||
|
||||
type CosmeticResult =
|
||||
| { type: "allowed"; cosmetics: PlayerCosmetics }
|
||||
| { type: "forbidden"; reason: string };
|
||||
@@ -161,6 +188,15 @@ export interface PrivilegeChecker {
|
||||
username: string,
|
||||
clanTag: string | null,
|
||||
): { username: string; clanTag: string | null };
|
||||
/**
|
||||
* Decide whether a player may wear the given (already-censored) clan tag.
|
||||
* Members keep their tag; impersonated or unverifiable tags are dropped.
|
||||
* `ownedClanTags` are the tags the player belongs to.
|
||||
*/
|
||||
resolveClanTag(
|
||||
censoredTag: string | null,
|
||||
ownedClanTags: string[],
|
||||
): ClanTagResolution;
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
@@ -170,10 +206,20 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
bannedWords: string[],
|
||||
// Every registered clan tag (uppercase). Polled by PrivilegeRefresher so
|
||||
// ownership is resolved in memory — no per-join existence probe.
|
||||
private reservedClanTags: Set<string> = new Set(),
|
||||
) {
|
||||
this.matcher = createMatcher(bannedWords);
|
||||
}
|
||||
|
||||
resolveClanTag(
|
||||
censoredTag: string | null,
|
||||
ownedClanTags: string[],
|
||||
): ClanTagResolution {
|
||||
return decideClanTag(censoredTag, ownedClanTags, this.reservedClanTags);
|
||||
}
|
||||
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
const cosmetics: PlayerCosmetics = {};
|
||||
if (refs.patternName) {
|
||||
@@ -325,4 +371,14 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
): { username: string; clanTag: string | null } {
|
||||
return censorWithMatcher(username, clanTag, defaultMatcher);
|
||||
}
|
||||
|
||||
// No reserved-tag list while cosmetics infra is unavailable (null), so a
|
||||
// non-member's tag is treated as reserved and dropped fail-closed to block
|
||||
// impersonation. Members are still known from their own tag list.
|
||||
resolveClanTag(
|
||||
censoredTag: string | null,
|
||||
ownedClanTags: string[],
|
||||
): ClanTagResolution {
|
||||
return decideClanTag(censoredTag, ownedClanTags, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { base64url } from "jose";
|
||||
import { Logger } from "winston";
|
||||
import { ReservedClanTagsResponseSchema } from "../core/ClanApiSchemas";
|
||||
import { CosmeticsSchema } from "../core/CosmeticSchemas";
|
||||
import { startPolling } from "./PollingLoop";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ export class PrivilegeRefresher {
|
||||
private cosmeticsEndpoint: string,
|
||||
private profaneWordsEndpoint: string,
|
||||
private apiKey: string,
|
||||
private reservedClanTagsEndpoint: string,
|
||||
parentLog: Logger,
|
||||
private refreshInterval: number = 1000 * 60 * 3,
|
||||
) {
|
||||
@@ -58,9 +60,14 @@ export class PrivilegeRefresher {
|
||||
}
|
||||
};
|
||||
|
||||
const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([
|
||||
const [
|
||||
cosmeticsResponse,
|
||||
profaneWordsResponse,
|
||||
reservedClanTagsResponse,
|
||||
] = await Promise.all([
|
||||
fetchWithTimeout(this.cosmeticsEndpoint),
|
||||
fetchWithTimeout(this.profaneWordsEndpoint),
|
||||
fetchWithTimeout(this.reservedClanTagsEndpoint),
|
||||
]);
|
||||
|
||||
if (!cosmeticsResponse || !cosmeticsResponse.ok) {
|
||||
@@ -76,6 +83,26 @@ export class PrivilegeRefresher {
|
||||
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
|
||||
}
|
||||
|
||||
// Reserved clan tags are critical: a missing or malformed list would
|
||||
// make every non-member tag look fictional and let impersonation
|
||||
// through. Throw so the previous (good) checker is retained instead.
|
||||
if (!reservedClanTagsResponse || !reservedClanTagsResponse.ok) {
|
||||
throw new Error(
|
||||
`Reserved clan tags HTTP error! status: ${reservedClanTagsResponse?.status ?? "network error"}`,
|
||||
);
|
||||
}
|
||||
const reservedClanTagsData = await reservedClanTagsResponse.json();
|
||||
const reservedClanTagsResult =
|
||||
ReservedClanTagsResponseSchema.safeParse(reservedClanTagsData);
|
||||
if (!reservedClanTagsResult.success) {
|
||||
throw new Error(
|
||||
`Invalid reserved clan tags data: ${reservedClanTagsResult.error.message}`,
|
||||
);
|
||||
}
|
||||
const reservedClanTags = new Set(
|
||||
reservedClanTagsResult.data.map((tag) => tag.toUpperCase()),
|
||||
);
|
||||
|
||||
let bannedWords: string[] = [];
|
||||
if (profaneWordsResponse && profaneWordsResponse.ok) {
|
||||
try {
|
||||
@@ -96,11 +123,14 @@ export class PrivilegeRefresher {
|
||||
result.data,
|
||||
base64url.decode,
|
||||
bannedWords,
|
||||
reservedClanTags,
|
||||
);
|
||||
this.cosmeticFlagUrls = new Set(
|
||||
Object.values(result.data.flags).map((f) => f.url),
|
||||
);
|
||||
this.log.info(`Privilege checker loaded successfully`);
|
||||
this.log.info(
|
||||
`Privilege checker loaded successfully (${reservedClanTags.size} reserved clan tags)`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to load privilege checker:`, error);
|
||||
throw error;
|
||||
|
||||
+19
-1
@@ -73,6 +73,7 @@ export async function startWorker() {
|
||||
ServerEnv.jwtIssuer() + "/cosmetics.json",
|
||||
ServerEnv.jwtIssuer() + "/profane_words_game_server",
|
||||
ServerEnv.apiKey(),
|
||||
ServerEnv.jwtIssuer() + "/reserved_clan_tags",
|
||||
log,
|
||||
);
|
||||
privilegeRefresher.start();
|
||||
@@ -378,6 +379,7 @@ export async function startWorker() {
|
||||
let flares: string[] | undefined;
|
||||
let publicId: string | undefined;
|
||||
let friends: string[] = [];
|
||||
let ownedClanTags: string[] = [];
|
||||
|
||||
const allowedFlares = ServerEnv.allowedFlares();
|
||||
if (claims === null) {
|
||||
@@ -400,6 +402,7 @@ export async function startWorker() {
|
||||
flares = result.response.player.flares;
|
||||
publicId = result.response.player.publicId;
|
||||
friends = result.response.player.friends;
|
||||
ownedClanTags = result.response.player.clans?.map((c) => c.tag) ?? [];
|
||||
|
||||
if (allowedFlares !== undefined) {
|
||||
const allowed =
|
||||
@@ -415,6 +418,21 @@ export async function startWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce clan tag ownership: a player can wear a tag only if they're
|
||||
// a member; a real clan they're not in (or an unverifiable tag) is
|
||||
// dropped to prevent impersonation. Fictional tags pass through.
|
||||
const resolution = privilegeRefresher
|
||||
.get()
|
||||
.resolveClanTag(censoredClanTag, ownedClanTags);
|
||||
if (resolution.dropped) {
|
||||
log.warn("Dropped clan tag: player is not a member", {
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
clanTag: censoredClanTag,
|
||||
});
|
||||
}
|
||||
const resolvedClanTag = resolution.tag;
|
||||
|
||||
const cosmeticResult = privilegeRefresher
|
||||
.get()
|
||||
.isAllowed(flares ?? [], clientMsg.cosmetics ?? {});
|
||||
@@ -463,7 +481,7 @@ export async function startWorker() {
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
censoredClanTag,
|
||||
resolvedClanTag,
|
||||
ws,
|
||||
cosmeticResult.cosmetics,
|
||||
publicId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
createMatcher,
|
||||
FailOpenPrivilegeChecker,
|
||||
PrivilegeCheckerImpl,
|
||||
shadowNames,
|
||||
} from "../src/server/Privilege";
|
||||
@@ -519,3 +520,68 @@ describe("Skin validation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrivilegeCheckerImpl#resolveClanTag", () => {
|
||||
// Reserved tags are stored uppercase, exactly as PrivilegeRefresher loads them.
|
||||
const makeChecker = (reservedTags: string[]) =>
|
||||
new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
new Set(reservedTags),
|
||||
);
|
||||
|
||||
it("passes a null tag through unchanged", () => {
|
||||
const result = makeChecker(["ABC"]).resolveClanTag(null, []);
|
||||
expect(result).toEqual({ tag: null, dropped: false });
|
||||
});
|
||||
|
||||
it("accepts a member's tag without consulting the reserved set (case-insensitive)", () => {
|
||||
const result = makeChecker(["ABC"]).resolveClanTag("ABC", ["abc"]);
|
||||
expect(result).toEqual({ tag: "ABC", dropped: false });
|
||||
});
|
||||
|
||||
it("drops a reserved tag the player does not belong to (impersonation)", () => {
|
||||
const result = makeChecker(["ABC"]).resolveClanTag("ABC", ["other"]);
|
||||
expect(result).toEqual({ tag: null, dropped: true });
|
||||
});
|
||||
|
||||
it("keeps a fictional tag matching no reserved clan", () => {
|
||||
const result = makeChecker(["OTHER"]).resolveClanTag("ABC", []);
|
||||
expect(result).toEqual({ tag: "ABC", dropped: false });
|
||||
});
|
||||
|
||||
it("matches the reserved set case-insensitively", () => {
|
||||
const result = makeChecker(["ABC"]).resolveClanTag("abc", ["other"]);
|
||||
expect(result).toEqual({ tag: null, dropped: true });
|
||||
});
|
||||
|
||||
it("treats anonymous users as members of no clans", () => {
|
||||
const result = makeChecker(["ABC"]).resolveClanTag("ABC", []);
|
||||
expect(result).toEqual({ tag: null, dropped: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("FailOpenPrivilegeChecker#resolveClanTag", () => {
|
||||
const checker = new FailOpenPrivilegeChecker();
|
||||
|
||||
it("passes a null tag through unchanged", () => {
|
||||
const result = checker.resolveClanTag(null, []);
|
||||
expect(result).toEqual({ tag: null, dropped: false });
|
||||
});
|
||||
|
||||
it("keeps a member's tag (known from owned tags, no lookup needed)", () => {
|
||||
const result = checker.resolveClanTag("ABC", ["abc"]);
|
||||
expect(result).toEqual({ tag: "ABC", dropped: false });
|
||||
});
|
||||
|
||||
it("drops a non-member's tag fail-closed (no reserved set while infra is down)", () => {
|
||||
const result = checker.resolveClanTag("ABC", ["other"]);
|
||||
expect(result).toEqual({ tag: null, dropped: true });
|
||||
});
|
||||
|
||||
it("drops an anonymous user's tag fail-closed", () => {
|
||||
const result = checker.resolveClanTag("ABC", []);
|
||||
expect(result.dropped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,109 @@ 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");
|
||||
expect(fetchSpy.mock.calls[0]![0] as string).toContain(
|
||||
"/public/clan/ABC/exists",
|
||||
);
|
||||
await fetchClanExists("a/b");
|
||||
expect(fetchSpy.mock.calls[1]![0] as string).toContain(
|
||||
"/public/clan/A%2FB/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",
|
||||
|
||||
Reference in New Issue
Block a user