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:
Ryan
2026-06-03 22:25:55 +01:00
committed by GitHub
parent 297e1f579e
commit 9c2ac05506
11 changed files with 430 additions and 16 deletions
+3 -1
View File
@@ -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
View File
@@ -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"
+8 -1
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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) {
+6
View File
@@ -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(),
+56
View File
@@ -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);
}
}
+32 -2
View File
@@ -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
View File
@@ -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,
+66
View File
@@ -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);
});
});
+128
View File
@@ -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",