diff --git a/index.html b/index.html index 4af07ad2d6..f824addc9e 100644 --- a/index.html +++ b/index.html @@ -329,6 +329,9 @@ + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 187677aa90..e88171ba4a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -994,6 +994,7 @@ "unfavorite": "Remove from favourites" }, "matchmaking_button": { + "connection_issue": "Couldn't verify your login. Please try again.", "description": "(ALPHA)", "login_required": "Login to play ranked!", "must_login": "You must be logged in to play ranked matchmaking.", @@ -1221,6 +1222,12 @@ "slider_tooltip": "{percent}% • {amount}", "title_with_name": "Send Troops to {name}" }, + "session_expired": { + "body": "Your session has expired. Please log in again to continue.", + "dismiss": "Dismiss", + "log_in": "Log in", + "title": "Signed out" + }, "single_modal": { "bots": "Tribes: ", "bots_disabled": "Disabled", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 6fa65a669a..567c78bc89 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -585,7 +585,7 @@ export class AccountModal extends BaseModal { } private async handleLogout() { - await logOut(); + await logOut({ userInitiated: true }); this.close(); // Refresh the page after logout to update the UI state window.location.reload(); diff --git a/src/client/Api.ts b/src/client/Api.ts index 9659ed9a1a..f9672bed4f 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -11,7 +11,12 @@ import { UserMeResponseSchema, } from "../core/ApiSchemas"; import { AnalyticsRecord, AnalyticsRecordSchema } from "../core/Schemas"; -import { getAuthHeader, logOut, userAuth } from "./Auth"; +import { + getAuthHeader, + LINKED_ACCOUNT_KEY, + markAuthOutcome, + userAuth, +} from "./Auth"; export async function fetchPlayerById( playerId: string, @@ -53,14 +58,25 @@ export async function fetchPlayerById( } } +// Whether the signed-in user had a linked account, used to decide whether a +// session-expiry should prompt re-login (linked users) or stay silent (guests). +// Backed by localStorage (LINKED_ACCOUNT_KEY) so it survives a reload and a +// transient /users/@me outage — it records what the user *was*, which is exactly +// what we need when the session later expires. +export function wasLinkedAccount(): boolean { + return localStorage.getItem(LINKED_ACCOUNT_KEY) === "true"; +} + let __userMe: Promise | null = null; export async function getUserMe(): Promise { if (__userMe !== null) { return __userMe; } - __userMe = (async () => { + const pending = (async () => { try { const userAuthResult = await userAuth(); + // No usable token: refreshJwt() already recorded why (transient/expired), + // so don't overwrite that outcome here. if (!userAuthResult) return false; const { jwt } = userAuthResult; @@ -70,24 +86,49 @@ export async function getUserMe(): Promise { authorization: `Bearer ${jwt}`, }, }); - if (response.status === 401) { - await logOut(); + // A non-200 here (including a 401) is treated as transient/ambiguous, not + // an authoritative logout: the JWT was just validated by userAuth(), so we + // neither revoke the session nor wipe identity. We record the outcome so + // callers can offer "try again" instead of bouncing a valid session. + if (response.status !== 200) { + markAuthOutcome("transient"); return false; } - if (response.status !== 200) return false; const body = await response.json(); const result = UserMeResponseSchema.safeParse(body); if (!result.success) { const error = z.prettifyError(result.error); console.error("Invalid response", error); + markAuthOutcome("transient"); return false; } + markAuthOutcome("ok"); + localStorage.setItem( + LINKED_ACCOUNT_KEY, + hasLinkedAccount(result.data) ? "true" : "false", + ); return result.data; } catch (e) { + // Network error reaching /users/@me — transient, not a logout. + markAuthOutcome("transient"); return false; } })(); - return __userMe; + __userMe = pending; + // Only memoize a successful result; a `false` (transient/ambiguous failure) + // must not stick for the whole page session, or recovery would need a full + // reload. Clear the cache on failure so the next call retries — but only while + // we still own the slot, so a stale in-flight request that settles after an + // invalidate + newer request can't clobber the newer one. + void pending.then( + (result) => { + if (result === false && __userMe === pending) __userMe = null; + }, + () => { + if (__userMe === pending) __userMe = null; + }, + ); + return pending; } export function invalidateUserMe() { @@ -115,7 +156,10 @@ export async function purchaseWithCurrency( }), }); if (response.status === 401) { - await logOut(); + // A 401 here is ambiguous (a transient/edge rejection is possible), so we + // return false WITHOUT logging out — a spurious 401 must not revoke the + // session. A genuinely expired token is handled by the central refresh + // path on the next authenticated call. return false; } if (!response.ok) { @@ -178,7 +222,10 @@ export async function cancelSubscription(): Promise { }, }); if (response.status === 401) { - await logOut(); + // A 401 here is ambiguous (a transient/edge rejection is possible), so we + // return false WITHOUT logging out — a spurious 401 must not revoke the + // session. A genuinely expired token is handled by the central refresh + // path on the next authenticated call. return false; } if (!response.ok) { @@ -212,7 +259,10 @@ export async function changeSubscriptionTier( }, ); if (response.status === 401) { - await logOut(); + // A 401 here is ambiguous (a transient/edge rejection is possible), so we + // return false WITHOUT logging out — a spurious 401 must not revoke the + // session. A genuinely expired token is handled by the central refresh + // path on the next authenticated call. return false; } if (!response.ok) { @@ -243,7 +293,10 @@ export async function openSubscriptionPortal(): Promise { }), }); if (response.status === 401) { - await logOut(); + // A 401 here is ambiguous (a transient/edge rejection is possible), so we + // return false WITHOUT logging out — a spurious 401 must not revoke the + // session. A genuinely expired token is handled by the central refresh + // path on the next authenticated call. return false; } if (!response.ok) { diff --git a/src/client/Auth.ts b/src/client/Auth.ts index e2b51e3829..9f11434f95 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -9,6 +9,11 @@ import { generateCryptoRandomUUID } from "./Utils"; export type UserAuth = { jwt: string; claims: TokenPayload } | false; const PERSISTENT_ID_KEY = "player_persistent_id"; +// Remembers whether the signed-in user had a linked account, so a later +// session-expiry can prompt re-login (linked users) vs. stay silent (guests) +// even when /users/@me can't be reached at that moment. Persisted (not just +// in-memory) so it survives a reload and an @me outage; cleared on logout. +export const LINKED_ACCOUNT_KEY = "was_linked_account"; let __jwt: string | null = null; let __refreshPromise: Promise | null = null; @@ -77,7 +82,22 @@ export async function getAuthHeader(): Promise { return `Bearer ${jwt}`; } -export async function logOut(allSessions: boolean = false): Promise { +export interface LogOutOptions { + /** Revoke every session (/auth/revoke) instead of just the current one. */ + allSessions?: boolean; + /** + * Set only for an explicit, user-initiated logout — the one case where we + * also wipe the local persistent identity + cosmetics. Error-path callers + * must leave this false, or a transient failure becomes a permanent new + * guest account. + */ + userInitiated?: boolean; +} + +export async function logOut({ + allSessions = false, + userInitiated = false, +}: LogOutOptions = {}): Promise { try { const response = await fetch( getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"), @@ -98,9 +118,15 @@ export async function logOut(allSessions: boolean = false): Promise { return false; } finally { __jwt = null; - localStorage.removeItem(PERSISTENT_ID_KEY); - new UserSettings().clearFlag(); - new UserSettings().setSelectedPatternName(undefined); + // Only destroy the local persistent identity / cosmetics on an explicit + // user logout. Error-path callers must NOT wipe identity, or a transient + // failure turns into a permanent brand-new guest account. + if (userInitiated) { + localStorage.removeItem(PERSISTENT_ID_KEY); + localStorage.removeItem(LINKED_ACCOUNT_KEY); + new UserSettings().clearFlag(); + new UserSettings().setSelectedPatternName(undefined); + } } } @@ -188,29 +214,117 @@ async function refreshJwt(): Promise { } } -async function doRefreshJwt(): Promise { +// Outcome of the most recent authentication check — a /auth/refresh attempt or +// a /users/@me call — so callers (e.g. ranked matchmaking) can tell "your +// session expired" apart from "we couldn't reach the auth server right now". It +// spans both layers because either can be the call that just failed. +export type AuthOutcome = "ok" | "expired" | "transient"; +let __lastAuthOutcome: AuthOutcome = "ok"; + +export function getLastAuthOutcome(): AuthOutcome { + return __lastAuthOutcome; +} + +// Lets the /users/@me path (Api.ts) record its own outcome into the same signal, +// so the transient-vs-expired distinction reflects whichever call last ran. +export function markAuthOutcome(outcome: AuthOutcome): void { + __lastAuthOutcome = outcome; +} + +const REFRESH_MAX_ATTEMPTS = 3; +// Backoff between attempts, indexed by (attempt - 1): 500ms, then 1000ms. +const REFRESH_BACKOFF_MS = [500, 1000]; +const REFRESH_TIMEOUT_MS = 10_000; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// fetch() with a per-attempt timeout, so a stalled connection can't hang the +// shared refresh promise (and every caller awaiting it) indefinitely. +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); try { - console.log("Refreshing jwt"); - const response = await fetch(getApiBase() + "/auth/refresh", { - method: "POST", - credentials: "include", - }); - if (response.status !== 200) { - console.error("Refresh failed", response); - logOut(); - return; + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function notifySessionExpired(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent("auth-session-expired")); +} + +async function doRefreshJwt(): Promise { + // Whether an authenticated session was active before this attempt. Only a + // session that dies mid-use should surface the "signed out" UI. + const hadSession = __jwt !== null; + + for (let attempt = 1; attempt <= REFRESH_MAX_ATTEMPTS; attempt++) { + try { + console.log( + `Refreshing jwt (attempt ${attempt}/${REFRESH_MAX_ATTEMPTS})`, + ); + const response = await fetchWithTimeout( + getApiBase() + "/auth/refresh", + { method: "POST", credentials: "include" }, + REFRESH_TIMEOUT_MS, + ); + + if (response.status === 200) { + const json = await response.json(); + const { jwt, expiresIn } = json; + // A 200 with an unusable body would otherwise corrupt our state + // (undefined jwt makes hadSession lie; NaN expiry disables future + // refresh). Treat it as a transient failure and retry instead. + if (typeof jwt === "string" && typeof expiresIn === "number") { + __expiresAt = Date.now() + expiresIn * 1000; + __jwt = jwt; + __lastAuthOutcome = "ok"; + console.log("Refresh succeeded"); + return; + } + console.error("Refresh returned 200 with an invalid body"); + } else if (response.status === 401 || response.status === 403) { + // 401/403 are definitive: the refresh token is genuinely invalid or + // expired, so retrying can't help. Do a "soft" logout — clear the + // in-memory JWT but preserve the session cookie + persistent identity — + // and let a previously-signed-in user be prompted to log in again. + console.error("Refresh rejected — session expired", response.status); + __jwt = null; + __lastAuthOutcome = "expired"; + if (hadSession) notifySessionExpired(); + return; + } else { + // Everything else (5xx, 429, ...) is transient — fall through to retry. + console.error( + `Refresh failed (status ${response.status}), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`, + ); + } + } catch (e) { + // Network error / timeout / server unreachable — transient, retry. + console.error( + `Refresh failed (network), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`, + e, + ); + } + + if (attempt < REFRESH_MAX_ATTEMPTS) { + await delay(REFRESH_BACKOFF_MS[attempt - 1] ?? 1000); } - const json = await response.json(); - const { jwt, expiresIn } = json; - __expiresAt = Date.now() + expiresIn * 1000; - console.log("Refresh succeeded"); - __jwt = jwt; - } catch (e) { - console.error("Refresh failed", e); - // if server unreachable, just clear jwt - __jwt = null; - return; } + + // Transient failures exhausted. Clear the in-memory JWT only — keep the + // session cookie and persistent identity so the next refresh can recover. + console.error("Refresh failed after retries; staying recoverable"); + __jwt = null; + __lastAuthOutcome = "transient"; } export async function sendMagicLink(email: string): Promise { diff --git a/src/client/Main.ts b/src/client/Main.ts index b3dabb9ada..aa16a1324e 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -43,6 +43,7 @@ import { modalRouter } from "./ModalRouter"; import { initNavigation } from "./Navigation"; import "./NewsModal"; import "./PatternInput"; +import "./SessionExpiredModal"; import "./SinglePlayerModal"; import { StoreModal } from "./Store"; import "./TerritoryPatternsModal"; @@ -505,14 +506,19 @@ class Client { } }; - if ((await userAuth()) === false) { - // Not logged in - onUserMe(false); - } else { - // JWT appears to be valid - // TODO: Add caching - getUserMe().then(onUserMe); - } + // Resolve auth in the background: a slow/degraded auth server (now retried a + // few times) must not delay menu wiring or deep-link lobby joins below. The + // join path re-checks auth itself, and refreshes are single-flighted. + void userAuth().then((auth) => { + if (auth === false) { + // Not logged in + onUserMe(false); + } else { + // JWT appears to be valid + // TODO: Add caching + void getUserMe().then(onUserMe); + } + }); const settingsModal = document.querySelector( "user-setting", diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index ed848430e1..483946e513 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -3,12 +3,12 @@ import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { UserMeResponse } from "../core/ApiSchemas"; import { getUserMe, hasLinkedAccount } from "./Api"; -import { getPlayToken } from "./Auth"; +import { getLastAuthOutcome, getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; import { JoinLobbyEvent } from "./Main"; -import { translateText } from "./Utils"; +import { showToast, translateText } from "./Utils"; @customElement("matchmaking-modal") export class MatchmakingModal extends BaseModal { @@ -126,17 +126,24 @@ export class MatchmakingModal extends BaseModal { userMe.user.google !== undefined || userMe.user.email !== undefined); if (!isLoggedIn) { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: translateText("matchmaking_button.must_login"), - color: "red", - duration: 3000, - }, - }), + // Distinguish a transient auth hiccup (server unreachable / 5xx, or a + // /users/@me blip) from a genuine "not logged in": only the latter should + // bounce the player to the login page; the former just asks them to retry. + // `userMe === false` means we couldn't get an answer (transient possible); + // a returned-but-unlinked userMe is a confirmed guest who must log in. + const transient = + userMe === false && getLastAuthOutcome() === "transient"; + showToast( + translateText( + transient + ? "matchmaking_button.connection_issue" + : "matchmaking_button.must_login", + ), + "red", + 3000, ); this.close(); - window.showPage?.("page-account"); + if (!transient) window.showPage?.("page-account"); return; } diff --git a/src/client/SessionExpiredModal.ts b/src/client/SessionExpiredModal.ts new file mode 100644 index 0000000000..3e7fe7ffc3 --- /dev/null +++ b/src/client/SessionExpiredModal.ts @@ -0,0 +1,66 @@ +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { wasLinkedAccount } from "./Api"; +import { BaseModal, ModalConfig } from "./components/BaseModal"; +import { translateText } from "./Utils"; + +/** + * App-level warning shown when a previously-signed-in user's auth session + * expires (a definitive 401/403 on /auth/refresh). Auth.ts dispatches the + * "auth-session-expired" window event; we only surface it for users who were + * actually logged in to an account — guests get a fresh session silently. + */ +@customElement("session-expired-modal") +export class SessionExpiredModal extends BaseModal { + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("auth-session-expired", this.onSessionExpired); + } + + disconnectedCallback(): void { + window.removeEventListener("auth-session-expired", this.onSessionExpired); + super.disconnectedCallback(); + } + + private onSessionExpired = (): void => { + if (!wasLinkedAccount()) return; + if (this.isOpen()) return; + this.open(); + }; + + protected modalConfig(): ModalConfig { + return { + title: translateText("session_expired.title"), + hideHeader: false, + hideCloseButton: false, + maxWidth: "420px", + }; + } + + private logIn(): void { + this.close(); + window.showPage?.("page-account"); + } + + protected renderBody() { + return html` +
+

${translateText("session_expired.body")}

+
+ this.close()} + > + this.logIn()} + > +
+
+ `; + } +} diff --git a/tests/client/Api.test.ts b/tests/client/Api.test.ts new file mode 100644 index 0000000000..9aec6e0f33 --- /dev/null +++ b/tests/client/Api.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// getUserMe()/getAuthHeader() resolve auth via ./Auth. Mock it so we can drive +// the token + outcome bookkeeping deterministically and observe what Api records. +const { userAuthMock, markAuthOutcomeMock, getAuthHeaderMock } = vi.hoisted( + () => ({ + userAuthMock: vi.fn(), + markAuthOutcomeMock: vi.fn(), + getAuthHeaderMock: vi.fn(), + }), +); + +vi.mock("../../src/client/Auth", () => ({ + userAuth: userAuthMock, + getAuthHeader: getAuthHeaderMock, + markAuthOutcome: markAuthOutcomeMock, + LINKED_ACCOUNT_KEY: "was_linked_account", +})); + +import { + cancelSubscription, + getUserMe, + invalidateUserMe, + wasLinkedAccount, +} from "../../src/client/Api"; + +const LINKED_ACCOUNT_KEY = "was_linked_account"; + +function userMeBody(linked: boolean) { + return { + user: linked ? { email: "player@example.com" } : {}, + player: { + publicId: "public-123", + adfree: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + }, + }; +} + +const okJson = (data: unknown) => ({ + status: 200, + ok: true, + json: async () => data, +}); + +describe("Api.getUserMe: transient handling and caching", () => { + beforeEach(() => { + localStorage.clear(); + invalidateUserMe(); + userAuthMock.mockReset(); + markAuthOutcomeMock.mockReset(); + getAuthHeaderMock.mockReset(); + getAuthHeaderMock.mockResolvedValue("Bearer test-token"); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("does not cache a transient /users/@me failure, so a retry can recover", async () => { + userAuthMock.mockResolvedValue({ jwt: "jwt-1", claims: {} }); + let meCalls = 0; + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/users/@me")) { + meCalls++; + if (meCalls === 1) { + return { status: 503, ok: false, json: async () => ({}) }; + } + return okJson(userMeBody(true)); + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + const first = await getUserMe(); + expect(first).toBe(false); + expect(markAuthOutcomeMock).toHaveBeenCalledWith("transient"); + + // The `false` was NOT memoized: a second call re-hits the server and the + // now-recovered backend succeeds. + const second = await getUserMe(); + expect(second).not.toBe(false); + expect(meCalls).toBe(2); + }); + + it("on success records linked status (persisted) and an 'ok' outcome", async () => { + userAuthMock.mockResolvedValue({ jwt: "jwt-1", claims: {} }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => + String(url).includes("/users/@me") + ? okJson(userMeBody(true)) + : okJson({}), + ), + ); + + const me = await getUserMe(); + expect(me).not.toBe(false); + expect(localStorage.getItem(LINKED_ACCOUNT_KEY)).toBe("true"); + expect(wasLinkedAccount()).toBe(true); + expect(markAuthOutcomeMock).toHaveBeenLastCalledWith("ok"); + }); + + it("remembers a guest (no linked account) as not-linked", async () => { + userAuthMock.mockResolvedValue({ jwt: "jwt-1", claims: {} }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => + String(url).includes("/users/@me") + ? okJson(userMeBody(false)) + : okJson({}), + ), + ); + + await getUserMe(); + expect(localStorage.getItem(LINKED_ACCOUNT_KEY)).toBe("false"); + expect(wasLinkedAccount()).toBe(false); + }); +}); + +describe("Api: authenticated endpoints don't destroy the session on a 401", () => { + beforeEach(() => { + localStorage.clear(); + invalidateUserMe(); + getAuthHeaderMock.mockReset(); + getAuthHeaderMock.mockResolvedValue("Bearer test-token"); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("cancelSubscription returns false on 401 without calling /auth/logout", async () => { + const fetchMock = vi.fn(async (_url: unknown) => ({ + status: 401, + ok: false, + json: async () => ({}), + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await cancelSubscription(); + expect(result).toBe(false); + // A spurious 401 must not revoke the session. + expect( + fetchMock.mock.calls.some(([u]) => String(u).includes("/auth/logout")), + ).toBe(false); + }); +}); diff --git a/tests/client/Auth.test.ts b/tests/client/Auth.test.ts new file mode 100644 index 0000000000..db8217d73d --- /dev/null +++ b/tests/client/Auth.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Auth.ts derives the API origin from window.location via ./Api. Pin it so the +// fetch URLs are deterministic in the jsdom environment. +vi.mock("../../src/client/Api", () => ({ + getApiBase: () => "http://localhost:8787", + getAudience: () => "localhost", +})); + +import { getLastAuthOutcome, logOut, userAuth } from "../../src/client/Auth"; + +const PERSISTENT_ID_KEY = "player_persistent_id"; + +// Build a decodeable JWT whose `iss` matches getApiBase() so userAuth()'s +// claim checks pass. Only the payload matters to decodeJwt. +function fakeJwt(payload: Record): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${b64({ alg: "none" })}.${b64(payload)}.sig`; +} + +const okJson = (data: unknown) => ({ + status: 200, + ok: true, + json: async () => data, +}); + +describe("Auth: refresh resilience and session-expiry handling", () => { + beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("retries transient (5xx) refresh failures, then preserves identity without logging out", async () => { + localStorage.setItem(PERSISTENT_ID_KEY, "keep-me-123"); + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/auth/refresh")) { + return { status: 503, ok: false, json: async () => ({}) }; + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await userAuth(); + + expect(result).toBe(false); + // Transient failures are retried (3 attempts) before giving up. + const refreshCalls = fetchMock.mock.calls.filter(([u]) => + String(u).includes("/auth/refresh"), + ).length; + expect(refreshCalls).toBe(3); + expect(getLastAuthOutcome()).toBe("transient"); + // Identity preserved and the session is never revoked on a transient blip. + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBe("keep-me-123"); + expect( + fetchMock.mock.calls.some(([u]) => String(u).includes("/auth/logout")), + ).toBe(false); + }); + + it("does NOT retry a definitive 401, and (no prior session) does not raise session-expired", async () => { + localStorage.setItem(PERSISTENT_ID_KEY, "keep-me-401"); + const onExpired = vi.fn(); + window.addEventListener("auth-session-expired", onExpired); + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/auth/refresh")) { + return { status: 401, ok: false, json: async () => ({}) }; + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await userAuth(); + + expect(result).toBe(false); + // 401 is definitive — exactly one attempt, no retry. + const refreshCalls = fetchMock.mock.calls.filter(([u]) => + String(u).includes("/auth/refresh"), + ).length; + expect(refreshCalls).toBe(1); + expect(getLastAuthOutcome()).toBe("expired"); + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBe("keep-me-401"); + // No active session existed, so we must not nag with the modal. + expect(onExpired).not.toHaveBeenCalled(); + window.removeEventListener("auth-session-expired", onExpired); + }); + + it("raises auth-session-expired when an ACTIVE session is rejected with a 401", async () => { + const onExpired = vi.fn(); + window.addEventListener("auth-session-expired", onExpired); + // First refresh mints a session (already-expired so the next userAuth + // re-refreshes); the second refresh is rejected. + let call = 0; + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/auth/refresh")) { + call++; + if (call === 1) { + return okJson({ + jwt: fakeJwt({ iss: "http://localhost:8787" }), + expiresIn: 0, + }); + } + return { status: 401, ok: false, json: async () => ({}) }; + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + await userAuth(); // establishes __jwt via the first (200) refresh + await userAuth(); // session now active -> second refresh 401s + + expect(getLastAuthOutcome()).toBe("expired"); + expect(onExpired).toHaveBeenCalledTimes(1); + window.removeEventListener("auth-session-expired", onExpired); + }); + + it("wipes local identity only on an explicit user-initiated logOut", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okJson({})), + ); + + localStorage.setItem(PERSISTENT_ID_KEY, "keep-me-456"); + await logOut(); // error-path / programmatic logout + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBe("keep-me-456"); + + await logOut({ userInitiated: true }); // the real "Log out" button + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBeNull(); + }); +});