mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-25 02:06:55 +00:00
Steam desktop authentication support (SteamSDK, Auth branch, audience config, username seeding) (#4683)
> **Maintainer note:** part of the private OpenFront Steam release (Milestone 2, tracked in Linear), not a public GitHub issue. Per the contributor policy this needs an approved/assigned issue or a maintainer label to avoid auto-close — flagging for a maintainer to handle. Resolves: _n/a — private Steam-release work (see note above)_ ## Description: Adds the client-side pieces for authenticating the OpenFront **Steam desktop build** (an Electron shell) against the existing API, mirroring the established CrazyGames integration. The native Steamworks code lives in the desktop shell and is exposed to this client through a `window.openfrontDesktop.steam` bridge; this PR is the renderer half. - **`SteamSDK.ts`** (new) — thin wrapper over the desktop shell's Steam bridge (`isOnSteam` / `getTicket` / `getUser`), mirroring `CrazyGamesSDK`; narrows the loosely-typed `window.openfrontDesktop` locally rather than re-declaring it. - **`Auth.ts`** — a Steam branch at the top of `doRefreshJwt()` plus `doSteamLogin()`, exchanging a Steam Web-API ticket at `POST /auth/steam` for a session JWT, exactly paralleling `doCrazyGamesLogin`. Re-exchanges on expiry (the refresh cookie is cross-site-blocked from the `app://` origin); no ticket / not on Steam falls through to the existing flow. - **`Api.ts`** — `getAudience()` now sources from `BOOTSTRAP_CONFIG` (`ClientEnv.jwtAudience()`) instead of `window.location`, so the desktop app (running at `app://openfront`) resolves the real API base. Behavior-preserving on web, where the injected audience already equals the hostname-derived one. - **`UsernameInput.ts`** — seeds the in-game display name from the Steam persona on first launch and persists it (unlike CrazyGames). Writes only the local display name; never the account username. - **`SteamLinkSignpost.ts`** (new) — a one-time, dismissible notice shown on first Steam launch that account linking is coming. ## Please complete the following: - [ ] I have added screenshots for all UI updates — ⚠️ _the new first-launch link signpost is a UI addition; it renders only inside the Steam desktop shell. Screenshots to be added by the maintainer._ - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — signpost strings go through `translateText()` with keys added to `resources/lang/en.json` (`steam.link_signpost`, `common.got_it`). - [x] I have added relevant tests to the test directory — unit tests for `SteamSDK` (incl. bridge-throws degradation), the `Auth.ts` Steam exchange + fall-through, the `Api.ts` audience change, username seeding, and the signpost gating. Full suite green (1976) + `tsc --noEmit` clean. ## Please put your Discord username so you can be contacted if a bug or regression is found: _⚠️ maintainer to fill_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BWxUzYb2uqjcBFQuNSJhMy
This commit is contained in:
@@ -207,6 +207,7 @@
|
||||
|
||||
<homepage-promos></homepage-promos>
|
||||
<marketing-consent-toast></marketing-consent-toast>
|
||||
<steam-link-signpost></steam-link-signpost>
|
||||
|
||||
<!-- Main container with responsive padding -->
|
||||
<main-layout class="contents">
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"duration_minute_short": "min",
|
||||
"duration_second_short": "s",
|
||||
"enabled": "Enabled",
|
||||
"got_it": "Got it",
|
||||
"map_default": "Map default",
|
||||
"month_apr": "April",
|
||||
"month_aug": "August",
|
||||
@@ -1406,6 +1407,9 @@
|
||||
"toggle_achievements": "Toggle achievements",
|
||||
"water_nukes": "Water nukes"
|
||||
},
|
||||
"steam": {
|
||||
"link_signpost": "Have an existing OpenFront account? Account linking is coming in a later update."
|
||||
},
|
||||
"store": {
|
||||
"already_subscribed": "Already subscribed.",
|
||||
"change_tier_failed": "Couldn't update your subscription. Please try again.",
|
||||
|
||||
+3
-3
@@ -690,9 +690,9 @@ export function getApiBase() {
|
||||
}
|
||||
|
||||
export function getAudience() {
|
||||
const { hostname } = new URL(window.location.href);
|
||||
const domainname = hostname.split(".").slice(-2).join(".");
|
||||
return domainname;
|
||||
// Sourced from BOOTSTRAP_CONFIG (server/desktop-injected) rather than
|
||||
// window.location, so the desktop app (app://openfront) targets real infra.
|
||||
return ClientEnv.jwtAudience();
|
||||
}
|
||||
|
||||
// Check if the user's account is linked to a Discord, Google, or email account.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
|
||||
import { base64urlToUuid } from "../core/Base64";
|
||||
import { getApiBase, getAudience } from "./Api";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { steamSDK } from "./SteamSDK";
|
||||
import { generateCryptoRandomUUID } from "./Utils";
|
||||
|
||||
export type UserAuth = { jwt: string; claims: TokenPayload } | false;
|
||||
@@ -190,6 +191,14 @@ async function refreshJwt(): Promise<void> {
|
||||
}
|
||||
|
||||
async function doRefreshJwt(): Promise<void> {
|
||||
if (steamSDK.isOnSteam()) {
|
||||
const ticket = await steamSDK.getTicket();
|
||||
if (ticket) {
|
||||
// On Steam, we exchange a Steam Web-API ticket for our session. No
|
||||
// ticket (Steam unavailable) falls through to the guest flow below.
|
||||
return doSteamLogin(ticket);
|
||||
}
|
||||
}
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
const token = await crazyGamesSDK.getUserToken();
|
||||
if (token) {
|
||||
@@ -250,6 +259,33 @@ async function doCrazyGamesLogin(token: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange a Steam Web-API ticket for our session. Like CrazyGames, the
|
||||
// refresh cookie isn't usable from app://openfront (cross-site), so we
|
||||
// re-exchange a fresh ticket on expiry rather than hitting /auth/refresh.
|
||||
async function doSteamLogin(ticket: string): Promise<void> {
|
||||
try {
|
||||
console.log("Logging in with Steam");
|
||||
const response = await fetch(getApiBase() + "/auth/steam", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticket }),
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
console.error("Steam login failed", response);
|
||||
__jwt = null;
|
||||
return;
|
||||
}
|
||||
const json = await response.json();
|
||||
const { jwt, expiresIn } = json;
|
||||
__expiresAt = Date.now() + expiresIn * 1000;
|
||||
console.log("Steam login succeeded");
|
||||
__jwt = jwt;
|
||||
} catch (e) {
|
||||
console.error("Steam login failed", e);
|
||||
__jwt = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the CrazyGames auth state changes mid-session (e.g. the player
|
||||
// signs in): drop the cached session so userAuth() re-exchanges the new token.
|
||||
// Single-flight: Main's auth listener and the account modal's sign-in handler
|
||||
|
||||
@@ -54,6 +54,7 @@ import "./NewsModal";
|
||||
import "./PlayerProfileModal";
|
||||
import { RewardsModal } from "./RewardsModal";
|
||||
import "./SinglePlayerModal";
|
||||
import "./SteamLinkSignpost";
|
||||
import { StoreModal } from "./Store";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import {
|
||||
@@ -937,6 +938,11 @@ class Client {
|
||||
}
|
||||
const auth = await userAuth();
|
||||
const playerRole = auth !== false ? (auth.claims.role ?? null) : null;
|
||||
// Ensure the one-shot Steam name-seed has settled before reading
|
||||
// getUsername(), mirroring how getClanCheck() runs in parallel with the
|
||||
// handshake. whenSeeded() always resolves (falling back to the generated
|
||||
// anon name on failure/timeout), so this can only delay, never block.
|
||||
await this.usernameInput?.whenSeeded();
|
||||
const newLobbyHandle = joinLobby(this.eventBus, {
|
||||
gameID: lobby.gameID,
|
||||
cosmetics: await getPlayerCosmeticsRefs(),
|
||||
|
||||
@@ -830,6 +830,11 @@ export class SinglePlayerModal extends BaseModal {
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
|
||||
// Wait for the one-shot Steam name-seed to settle before reading
|
||||
// getUsername(), so a fast single-player start uses the Steam persona
|
||||
// rather than the interim generated anon name. Always resolves.
|
||||
await usernameInput?.whenSeeded();
|
||||
|
||||
await crazyGamesSDK.requestMidgameAd();
|
||||
|
||||
this.dispatchEvent(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { steamSDK } from "./SteamSDK";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
const SEEN_KEY = "steam_link_signpost_seen";
|
||||
|
||||
/**
|
||||
* One-time first-launch signpost for the Steam desktop build. Account
|
||||
* linking (matching a Steam player to an existing web OpenFront account)
|
||||
* isn't implemented yet — a fresh install on Steam always creates a new
|
||||
* account. This tells the player that once, so they aren't surprised, and
|
||||
* never shows again once dismissed (or once seen, since `dismiss()` is the
|
||||
* only way the seen-flag gets set).
|
||||
*
|
||||
* Self-hides off Steam, so mounting it unconditionally (web + desktop) is
|
||||
* safe.
|
||||
*/
|
||||
@customElement("steam-link-signpost")
|
||||
export class SteamLinkSignpost extends LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
shouldShow(): boolean {
|
||||
return steamSDK.isOnSteam() && localStorage.getItem(SEEN_KEY) !== "1";
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
localStorage.setItem(SEEN_KEY, "1");
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow()) return nothing;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed bottom-4 left-4 right-4 z-[10000] sm:left-auto sm:right-4 sm:w-[300px] bg-surface border border-white/10 rounded-xl shadow-[var(--shadow-malibu-blue)] p-3"
|
||||
role="dialog"
|
||||
aria-label=${translateText("steam.link_signpost")}
|
||||
>
|
||||
<p class="text-xs text-white/80 mb-2">
|
||||
${translateText("steam.link_signpost")}
|
||||
</p>
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg border border-transparent bg-malibu-blue text-white shadow-[var(--shadow-malibu-blue-pill)] hover:bg-aquarius transition-all cursor-pointer"
|
||||
@click=${() => this.dismiss()}
|
||||
>
|
||||
${translateText("common.got_it")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
interface SteamBridge {
|
||||
getAuthTicket(): Promise<string | null>;
|
||||
getUser(): Promise<{ steamId: string; name: string } | null>;
|
||||
}
|
||||
|
||||
// window.openfrontDesktop is declared `unknown` by DesktopShell.ts (kept loose
|
||||
// there on purpose). We know the shape the Electron preload exposes, so narrow
|
||||
// it locally rather than re-declaring the global (a second `declare global`
|
||||
// with a different type triggers TS2717).
|
||||
function steamBridge(): SteamBridge | undefined {
|
||||
const desktop = window.openfrontDesktop as
|
||||
| { steam?: SteamBridge }
|
||||
| undefined;
|
||||
return desktop?.steam;
|
||||
}
|
||||
|
||||
// Thin renderer wrapper over the desktop shell's Steam bridge. Mirrors
|
||||
// CrazyGamesSDK; the native work lives in the Electron main process.
|
||||
class SteamSDK {
|
||||
isOnSteam(): boolean {
|
||||
return steamBridge() !== undefined;
|
||||
}
|
||||
|
||||
async getTicket(): Promise<string | null> {
|
||||
const bridge = steamBridge();
|
||||
if (!bridge) return null;
|
||||
try {
|
||||
return await bridge.getAuthTicket();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(): Promise<{ steamId: string; name: string } | null> {
|
||||
const bridge = steamBridge();
|
||||
if (!bridge) return null;
|
||||
try {
|
||||
return await bridge.getUser();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const steamSDK = new SteamSDK();
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { checkClanTagOwnership } from "./ClanApi";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { showInGameConfirm } from "./InGameModal";
|
||||
import { steamSDK } from "./SteamSDK";
|
||||
|
||||
interface LangSelectorLike {
|
||||
currentLang?: string;
|
||||
@@ -37,6 +38,9 @@ export class UsernameInput extends LitElement {
|
||||
|
||||
// Clans aren't supported on CrazyGames — hide the tag input and never submit one.
|
||||
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
|
||||
// Steam identity is fixed for the session (no login/logout events like
|
||||
// CrazyGames), so it's only used to seed the name once in connectedCallback.
|
||||
private readonly onSteam = steamSDK.isOnSteam();
|
||||
|
||||
@property({ type: String }) validationError: string = "";
|
||||
// Ownership-check feedback (i18n key) shown inline beneath the tag input. Only
|
||||
@@ -51,6 +55,13 @@ export class UsernameInput extends LitElement {
|
||||
private clanCheckGen = 0;
|
||||
private clanCheck: Promise<string | null> = Promise.resolve(null);
|
||||
|
||||
// Resolves once the one-shot Steam name-seed has settled (or immediately for
|
||||
// non-Steam players). The join flow awaits this before reading getUsername()
|
||||
// so a fast join can't start the game under the generated anon name before
|
||||
// the Steam persona lands. Always resolves — never rejects — so a failed or
|
||||
// slow getUser() falls back to the generated name instead of blocking.
|
||||
private steamSeedReady: Promise<void> = Promise.resolve();
|
||||
|
||||
// Remove static styles since we're using Tailwind
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -155,6 +166,15 @@ export class UsernameInput extends LitElement {
|
||||
return this.clanCheck;
|
||||
}
|
||||
|
||||
// Resolves once the one-shot Steam name-seed has settled (immediately for
|
||||
// non-Steam players, or once nothing was left to seed). The join flow awaits
|
||||
// this before reading getUsername() so a fast join reads the Steam persona
|
||||
// rather than the interim generated anon name. Never blocks: the underlying
|
||||
// chain always resolves, even when getUser() fails or the persona is invalid.
|
||||
public whenSeeded(): Promise<void> {
|
||||
return this.steamSeedReady;
|
||||
}
|
||||
|
||||
private startClanCheck() {
|
||||
const gen = ++this.clanCheckGen;
|
||||
const tag = this.clanTag;
|
||||
@@ -178,6 +198,10 @@ export class UsernameInput extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Captured before loadStoredUsername(), which — when nothing is stored —
|
||||
// fills in a fresh anon username AND persists it immediately. Checking
|
||||
// localStorage afterwards would therefore never see it as empty.
|
||||
const noStoredUsername = this.onSteam && !localStorage.getItem(usernameKey);
|
||||
this.loadStoredUsername();
|
||||
// On CrazyGames the account username is applied here but never persisted
|
||||
// (see loadStoredUsername / validateAndStore), so logging out — which
|
||||
@@ -196,6 +220,36 @@ export class UsernameInput extends LitElement {
|
||||
this.validateAndStore();
|
||||
}
|
||||
});
|
||||
// Seed the in-game name from the Steam persona, once, only when nothing
|
||||
// is stored yet. Unlike CrazyGames, Steam persists normally (see
|
||||
// validateAndStore's onCrazyGames guard), and there's no logout event to
|
||||
// handle since the Steam identity is fixed for the session.
|
||||
if (noStoredUsername) {
|
||||
// The anon name loadStoredUsername() just generated. Only overwrite it if
|
||||
// the player hasn't typed their own name while getUser() was in flight,
|
||||
// so a late Steam result never clobbers a name they entered.
|
||||
const generated = this.baseUsername;
|
||||
// Store the seeding promise so the join path can await it (see
|
||||
// whenSeeded). The chain never rejects — on any failure we keep the
|
||||
// generated name — so awaiting it can only delay, never block, a join.
|
||||
this.steamSeedReady = steamSDK
|
||||
.getUser()
|
||||
.then((user) => {
|
||||
if (this.baseUsername !== generated) return;
|
||||
// Steam personas can contain characters our usernames disallow (e.g.
|
||||
// brackets) or exceed the length limit; strip brackets, trim, and only
|
||||
// accept the persona if it validates — otherwise keep the generated
|
||||
// name so the player can always start a game.
|
||||
const candidate = user?.name?.replace(/[[\]]/g, "").trim();
|
||||
if (candidate && validateUsername(candidate).isValid) {
|
||||
this.baseUsername = candidate;
|
||||
this.validateAndStore();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Swallow: keep the generated name so the player can always play.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
|
||||
+42
-3
@@ -1,9 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { getApiBase } from "../src/client/Api";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getApiBase, getAudience } from "../src/client/Api";
|
||||
import { ClientEnv } from "../src/client/ClientEnv";
|
||||
|
||||
describe("getApiBase", () => {
|
||||
function setConfig(jwtAudience: string) {
|
||||
(window as any).BOOTSTRAP_CONFIG = {
|
||||
gameEnv: "prod",
|
||||
numWorkers: 1,
|
||||
turnstileSiteKey: "x",
|
||||
jwtAudience,
|
||||
instanceId: "desktop",
|
||||
gitCommit: "test",
|
||||
};
|
||||
ClientEnv.reset();
|
||||
}
|
||||
|
||||
// setConfig sets window.BOOTSTRAP_CONFIG; clear it (and the cached env) after
|
||||
// every test so a stray value can't leak into a later test.
|
||||
afterEach(() => {
|
||||
delete (window as any).BOOTSTRAP_CONFIG;
|
||||
ClientEnv.reset();
|
||||
});
|
||||
|
||||
describe("getApiBase localhost fallback", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// getAudience() now reads the audience from BOOTSTRAP_CONFIG, so the
|
||||
// localhost branch is reached via jwtAudience "localhost".
|
||||
setConfig("localhost");
|
||||
});
|
||||
|
||||
// API_DOMAIN is forced empty under vitest via the vite.config `define`, so this
|
||||
@@ -13,3 +36,19 @@ describe("getApiBase", () => {
|
||||
expect(getApiBase()).toBe("http://localhost:8787");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAudience / getApiBase from BOOTSTRAP_CONFIG", () => {
|
||||
beforeEach(() => ClientEnv.reset());
|
||||
|
||||
it("returns the configured audience (desktop staging)", () => {
|
||||
setConfig("openfront.dev");
|
||||
expect(getAudience()).toBe("openfront.dev");
|
||||
expect(getApiBase()).toBe("https://api.openfront.dev");
|
||||
});
|
||||
|
||||
it("returns the configured audience (prod)", () => {
|
||||
setConfig("openfront.io");
|
||||
expect(getAudience()).toBe("openfront.io");
|
||||
expect(getApiBase()).toBe("https://api.openfront.io");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { steamSDK } from "../src/client/SteamSDK";
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as any).openfrontDesktop;
|
||||
});
|
||||
|
||||
describe("SteamSDK", () => {
|
||||
it("isOnSteam is false without the bridge", () => {
|
||||
expect(steamSDK.isOnSteam()).toBe(false);
|
||||
});
|
||||
it("isOnSteam true and passes through ticket/user with the bridge", async () => {
|
||||
(window as any).openfrontDesktop = {
|
||||
steam: {
|
||||
getAuthTicket: vi.fn().mockResolvedValue("deadbeef"),
|
||||
getUser: vi.fn().mockResolvedValue({ steamId: "77", name: "Ada" }),
|
||||
},
|
||||
};
|
||||
expect(steamSDK.isOnSteam()).toBe(true);
|
||||
expect(await steamSDK.getTicket()).toBe("deadbeef");
|
||||
expect(await steamSDK.getUser()).toEqual({ steamId: "77", name: "Ada" });
|
||||
});
|
||||
it("getTicket degrades to null when bridge rejects", async () => {
|
||||
(window as any).openfrontDesktop = {
|
||||
steam: {
|
||||
getAuthTicket: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
getUser: vi.fn(),
|
||||
},
|
||||
};
|
||||
expect(await steamSDK.getTicket()).toBeNull();
|
||||
});
|
||||
it("getUser degrades to null when bridge rejects", async () => {
|
||||
(window as any).openfrontDesktop = {
|
||||
steam: {
|
||||
getAuthTicket: vi.fn(),
|
||||
getUser: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
},
|
||||
};
|
||||
expect(await steamSDK.getUser()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { UnsecuredJWT } from "jose";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAuthHeader, logOut } from "../../src/client/Auth";
|
||||
import { ClientEnv } from "../../src/client/ClientEnv";
|
||||
import { steamSDK } from "../../src/client/SteamSDK";
|
||||
|
||||
function setBootstrapConfig() {
|
||||
(window as any).BOOTSTRAP_CONFIG = {
|
||||
gameEnv: "prod",
|
||||
numWorkers: 1,
|
||||
turnstileSiteKey: "x",
|
||||
jwtAudience: "openfront.dev",
|
||||
instanceId: "d",
|
||||
gitCommit: "t",
|
||||
};
|
||||
ClientEnv.reset();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
setBootstrapConfig();
|
||||
await logOut();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Steam login", () => {
|
||||
it("exchanges a Steam ticket for a session JWT via POST /auth/steam", async () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
vi.spyOn(steamSDK, "getTicket").mockResolvedValue("ticket123");
|
||||
|
||||
const jwt = new UnsecuredJWT({
|
||||
jti: "some-id",
|
||||
sub: "AAAAAAAAAAAAAAAAAAAAAA",
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: "https://api.openfront.dev",
|
||||
aud: "openfront.dev",
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
}).encode();
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ jwt, expiresIn: 900 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const header = await getAuthHeader();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("/auth/steam");
|
||||
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
||||
ticket: "ticket123",
|
||||
});
|
||||
expect(header).toBe(`Bearer ${jwt}`);
|
||||
});
|
||||
|
||||
it("falls through to the guest/refresh flow when no Steam ticket is available", async () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
vi.spyOn(steamSDK, "getTicket").mockResolvedValue(null);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 401 }));
|
||||
|
||||
await getAuthHeader();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
expect(String(call[0])).not.toContain("/auth/steam");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SteamLinkSignpost } from "../../src/client/SteamLinkSignpost";
|
||||
import { steamSDK } from "../../src/client/SteamSDK";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SteamLinkSignpost", () => {
|
||||
it("is visible on first Steam launch, hidden after dismiss", () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
const el = new SteamLinkSignpost();
|
||||
expect(el.shouldShow()).toBe(true);
|
||||
el.dismiss();
|
||||
expect(localStorage.getItem("steam_link_signpost_seen")).toBe("1");
|
||||
expect(el.shouldShow()).toBe(false);
|
||||
});
|
||||
|
||||
it("never shows off Steam", () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(false);
|
||||
expect(new SteamLinkSignpost().shouldShow()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { steamSDK } from "../../src/client/SteamSDK";
|
||||
import { UsernameInput } from "../../src/client/UsernameInput";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("UsernameInput Steam seeding", () => {
|
||||
it("seeds and persists the Steam persona when nothing is stored", async () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
|
||||
steamId: "77",
|
||||
name: "Ada",
|
||||
});
|
||||
const el = new UsernameInput();
|
||||
el.connectedCallback();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(el.getUsername()).toBe("Ada");
|
||||
expect(localStorage.getItem("username")).toBe("Ada"); // usernameKey
|
||||
});
|
||||
|
||||
it("keeps an already-stored username", async () => {
|
||||
localStorage.setItem("username", "MyName");
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
|
||||
steamId: "77",
|
||||
name: "Ada",
|
||||
});
|
||||
const el = new UsernameInput();
|
||||
el.connectedCallback();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(el.getUsername()).toBe("MyName");
|
||||
});
|
||||
|
||||
it("strips brackets from the Steam persona", async () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
|
||||
steamId: "77",
|
||||
name: "[Ada]",
|
||||
});
|
||||
const el = new UsernameInput();
|
||||
el.connectedCallback();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(el.getUsername()).toBe("Ada");
|
||||
});
|
||||
|
||||
it("keeps a valid generated name when the persona is invalid", async () => {
|
||||
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
|
||||
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
|
||||
steamId: "77",
|
||||
name: "x".repeat(100),
|
||||
});
|
||||
const el = new UsernameInput();
|
||||
el.connectedCallback();
|
||||
// Captured synchronously, before the async getUser() seed resolves: this is
|
||||
// the generated anon name loadStoredUsername() just produced.
|
||||
const generated = el.getUsername();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// The invalid persona must be rejected and the exact generated name kept —
|
||||
// not merely replaced by some other valid name.
|
||||
expect(el.getUsername()).toBe(generated);
|
||||
expect(generated).not.toBe("x".repeat(100));
|
||||
expect(generated.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import featuredStream from "../../../resources/featured-stream.json";
|
||||
import { getFeaturedStream } from "../../../src/client/Api";
|
||||
import { ClientEnv } from "../../../src/client/ClientEnv";
|
||||
import { cornerFromCenter } from "../../../src/client/FeaturedStream";
|
||||
import { FeaturedStreamSchema } from "../../../src/core/ApiSchemas";
|
||||
|
||||
@@ -65,10 +66,25 @@ describe("FeaturedStream", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// getApiBase() → getAudience() now reads the JWT audience from
|
||||
// BOOTSTRAP_CONFIG (ClientEnv), so getFeaturedStream needs it present or
|
||||
// it throws before fetch and silently falls back. The value is irrelevant
|
||||
// here since fetch is stubbed; we only need a well-formed config.
|
||||
(window as any).BOOTSTRAP_CONFIG = {
|
||||
gameEnv: "prod",
|
||||
numWorkers: 1,
|
||||
turnstileSiteKey: "x",
|
||||
jwtAudience: "openfront.io",
|
||||
instanceId: "desktop",
|
||||
gitCommit: "test",
|
||||
};
|
||||
ClientEnv.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
delete (window as any).BOOTSTRAP_CONFIG;
|
||||
ClientEnv.reset();
|
||||
});
|
||||
|
||||
const stubFetch = (impl: () => unknown) =>
|
||||
|
||||
Reference in New Issue
Block a user