diff --git a/src/common/helper/auth-recovery.ts b/src/common/helper/auth-recovery.ts new file mode 100644 index 0000000..5bebe2b --- /dev/null +++ b/src/common/helper/auth-recovery.ts @@ -0,0 +1,267 @@ +import { authentication } from "@modular-rest/client"; +import { sendMessage } from "./massage"; +import { StoreUserTokenMessage } from "../types/messaging"; +import { debug, error, warn } from "./log"; + +/** + * Auth-recovery helper — the three pieces of "self-heal a dead session token" + * live together here because they're only ever useful as a unit: + * + * isAuthError(err) — is this failure a stale/invalid token? + * reauthAnonymously()— if so, how do we recover a session? + * withAuthRetry(run) — the policy that wires the two: retry once on recovery. + * + * Why this matters: anonymous users get purged server-side, so a token + * persisted in chrome.storage.sync can outlive its user and start returning + * "User not found" on every call — leaving the user stuck (e.g. on + * "Translation failed") with no way out short of a full reload. + */ + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +/** + * We can only inspect the *body* the server sent, never the HTTP status: + * modular-rest's HTTPClient.request discards `error.response.status` and + * re-throws only `error.response.data` wrapped as `{ hasError, error }` + * (node_modules/@modular-rest/client/dist/class/http.js). So detection is + * string-based against the response body. + * + * Two thrown shapes reach callers: + * - functionProvider.run re-throws the raw server body, e.g. "User not found". + * - dataProvider.* let the wrapper through, e.g. { hasError, error: "..." }. + * extractMessage flattens both into one searchable string. + * + * The patterns below were derived from the actual modular-rest server source + * (auth middleware → JWT verify → user lookup), not guessed. On every + * auth-gated route (/function/run, /data-provider/*) the server reports auth + * failures via Koa `ctx.throw`, so the BODY is a bare string: either the thrown + * message, or — when the message is undefined — Koa's default reason phrase. + * Verified failure → body mapping: + * - token valid but user row purged → 412 "User not found" + * - missing / empty Authorization → 401 "authentication is required" + * - lacks permission → 403 "access denied" + * - invalid/expired/wrong-sig/malformed token → 412 "Precondition Failed" + * ^ the jwt reason ("jwt expired" / "invalid signature" / "jwt malformed") + * is SWALLOWED server-side: JWT.verify rejects with the message as a + * *string*, so `err.message` is undefined and ctx.throw(412, undefined) + * falls back to Koa's reason phrase. "Precondition Failed" is therefore + * the ONLY on-the-wire signal for the most common stale-token case — + * and on these routes a bare "precondition failed" body can only come + * from the auth middleware (validation 412s carry a JSON body instead), + * so matching it does not risk a false positive. + * + * NOTE: /user/login & /user/loginAnonymous failures arrive as + * {status:"error", e:{}} (the Error serializes to {} — message lost). Those are + * deliberately NOT matched: login is an explicit user action and must not be + * silently retried as anonymous. If the upstream server stops swallowing the + * jwt message, the raw jsonwebtoken phrases below will start matching too. + */ +const AUTH_ERROR_PATTERNS = [ + // Bare-string bodies the server emits via ctx.throw on auth-gated routes. + "user not found", // 412 — token valid, user row purged (the reported bug) + "authentication", // 401 "authentication is required" (missing/empty header) + "access denied", // 403 — authenticated but lacks permission + "precondition failed", // 412 — invalid/expired/wrong-sig token (reason swallowed) + + // Raw jsonwebtoken messages — reach the client verbatim via /verify/token, + // and would reach the auth path too if the server stops swallowing them. + "jwt expired", + "jwt malformed", + "jwt not active", + "invalid signature", + "invalid token", + "jwt", // catch-all for other jsonwebtoken phrases ("jwt must be provided", …) + + // Defensive nets — not emitted by this server today, but cheap and guard the + // modular-rest client's own throws / intermediary proxies / gateways. + "unauthorized", + "forbidden", + "token doesn't", // client-side: "Token doesn't find on local machine" +]; + +function extractMessage(err: unknown, depth = 0): string { + if (err == null || depth > 3) return ""; + if (typeof err === "string") return err; + if (typeof err === "number" || typeof err === "boolean") return String(err); + if (typeof err === "object") { + const o = err as Record; + // The fields modular-rest / our own throws use to carry a message. + return [o.error, o.message, o.detail, o.reason, o.e, o.status] + .map((v) => extractMessage(v, depth + 1)) + .filter(Boolean) + .join(" "); + } + return ""; +} + +/** + * True when the error body matches a known auth/token-failure phrase. + * + * Deliberately conservative: business errors ("limit reached", "not enough + * credit") must NOT match, or we'd needlessly churn a perfectly good session + * — and a registered user could get silently degraded to anonymous. + */ +export function isAuthError(err: unknown): boolean { + const text = extractMessage(err).toLowerCase(); + if (!text) return false; + return AUTH_ERROR_PATTERNS.some((pattern) => text.includes(pattern)); +} + +// --------------------------------------------------------------------------- +// Recovery +// --------------------------------------------------------------------------- + +/** + * Establish a fresh anonymous session and persist its token to the background + * so every bundle on the page reuses it. Returns true once a usable session is + * in place. + * + * Persisting matters: subsequent mounts (other bundles on the same page, the + * popup, page reloads) reuse this token instead of each calling + * /user/loginAnonymous and stranding the previous anonymous user — which the + * server then 412s / "User not found"s on the next call. The write goes to + * chrome.storage.sync (cross-context) via the background script. + * + * Callers: + * 1. modular-rest.ts loginWithLastSession's fallback — first-session + * bootstrap when no valid stored token exists. + * 2. withAuthRetry (via recoverSession) — a previously-valid token went stale + * mid-session. + * + * SINGLE-FLIGHT: concurrent callers are coalesced into ONE /user/loginAnonymous + * and all reuse its token. This is essential — when a stored token is dead, a + * page typically fires several failing requests at once (the word-detail modal + * runs a simple + a detailed translation plus bundle look-ups together), and + * without coalescing each one would mint and strand its own anonymous user, + * producing the "constant loginAnonymous calls" storm. A re-auth that starts + * after the in-flight one settles is a fresh login (the guard resets). + * + * modular-rest.ts is the one that additionally refreshes the reactive isLogin + * ref via updateIsLogin after calling this. + */ +let inflightReauth: Promise | null = null; + +export function reauthAnonymously(): Promise { + if (inflightReauth) return inflightReauth; + inflightReauth = performAnonymousReauth().finally(() => { + inflightReauth = null; + }); + return inflightReauth; +} + +async function performAnonymousReauth(): Promise { + try { + await authentication.loginAsAnonymous(); + debug("Subturtle anonymous login succeeded", authentication.isLogin); + + const token = authentication.getToken; + if (token) { + try { + await sendMessage(new StoreUserTokenMessage(token)); + } catch (err) { + error( + "Subturtle: persisting anonymous token to background failed", + err + ); + } + } + + return authentication.isLogin; + } catch (err) { + // Raw console.error (not the [Subturtle]-prefixing helper) to preserve the + // exact message the anon-fallback has always logged — pinned by + // tests/auth-anon-flow.test.ts. + console.error("Subturtle anonymous login failed", err); + return false; + } +} + +// --------------------------------------------------------------------------- +// Recovery strategy (late-bound) +// --------------------------------------------------------------------------- + +/** + * The recovery withAuthRetry runs when it sees an auth error. Defaults to the + * bare reauthAnonymously (fresh anon token only). modular-rest.ts overrides it + * at init via setSessionRecovery with a system-wide recovery that ALSO tears + * the dead session down (logout broadcast + profile/isLogin/analytics reset) + * before re-establishing anonymous — see modular-rest.ts `recoverSession`. + * + * Late binding (rather than importing logout from the plugin) is deliberate: + * - the plugin already imports reauthAnonymously from THIS module, so a + * direct back-import would be circular; and + * - this module must stay side-effect-free so translate.service — and the + * many UI components importing it — don't drag the plugin's content-script + * side effects (GlobalOptions, chrome listeners, …) into their import graph + * or tests. The plugin is loaded by every bundle, so the override is always + * applied in production; code paths that never load it (some unit tests) + * fall back to the bare anonymous recovery, which is sufficient there. + */ +let sessionRecovery: () => Promise = reauthAnonymously; + +export function setSessionRecovery(recover: () => Promise): void { + sessionRecovery = recover; +} + +/** + * Single-flight wrapper around the installed recovery. A burst of failing + * requests (the word-detail modal fires several at once) must trigger ONE + * recovery, not one per request — otherwise the registered-user path would run + * logout() repeatedly and the anonymous path would still funnel through the + * (already coalesced) reauthAnonymously. All concurrent failures await the same + * recovery and then each retries its own call. + */ +let inflightRecovery: Promise | null = null; + +function recoverOnce(): Promise { + if (inflightRecovery) return inflightRecovery; + inflightRecovery = Promise.resolve(sessionRecovery()).finally(() => { + inflightRecovery = null; + }); + return inflightRecovery; +} + +// --------------------------------------------------------------------------- +// Retry policy +// --------------------------------------------------------------------------- + +/** + * Run a modular-rest call and, if it fails because the session token is + * stale/invalid, recover the session and retry the call once. + * + * Reusable across services — any call that depends on a valid session token + * can wrap itself in this to self-heal a dead token instead of surfacing a + * hard failure: + * + * import { withAuthRetry } from "@/common/helper/auth-recovery"; + * const data = await withAuthRetry(() => functionProvider.run({ ... })); + * + * Recovery is whatever setSessionRecovery installed (system-wide logout + + * anonymous re-auth in production). A registered user whose token is genuinely + * dead is cleanly downgraded to anonymous across the extension. + * + * Guarantees: + * - Only retries auth-shaped errors (isAuthError is conservative), so genuine + * failures — network, rate limit, business errors — surface unchanged. + * - Retries at most ONCE, and only if recovery actually produced a usable + * session, so it can never loop. + */ +export async function withAuthRetry(run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + if (!isAuthError(err)) throw err; + + warn( + "Request hit an auth error; recovering session and retrying once.", + err + ); + + const recovered = await recoverOnce(); + if (!recovered) throw err; + + return await run(); + } +} diff --git a/src/common/services/translate.service.ts b/src/common/services/translate.service.ts index cd5f079..203d85d 100644 --- a/src/common/services/translate.service.ts +++ b/src/common/services/translate.service.ts @@ -14,6 +14,7 @@ import { } from "../../console-crane/modules/word-detail/types"; import { LanguageDetector } from "../helper/language-detection"; import { useSettingsStore } from "../store/settings"; +import { withAuthRetry } from "../helper/auth-recovery"; // Cache interface for translation results interface TranslationCache { @@ -124,18 +125,20 @@ export class TranslateService { // If not cached, fetch from API try { - const result = await functionProvider.run({ - name: "translateWithContext", - args: { - translationType: "simple", - sourceLanguage: "auto", - targetLanguage: this.languageTitle, - phrase: text, - context: context || "", - // For the server-side translation_requested analytics event. - userId: authentication.user?.id, - }, - }); + const result = await withAuthRetry(() => + functionProvider.run({ + name: "translateWithContext", + args: { + translationType: "simple", + sourceLanguage: "auto", + targetLanguage: this.languageTitle, + phrase: text, + context: context || "", + // For the server-side translation_requested analytics event. + userId: authentication.user?.id, + }, + }) + ); // Cache the result this.cacheResult(cacheKey, result); @@ -163,18 +166,20 @@ export class TranslateService { // If not cached, fetch from API try { - const data = await functionProvider.run({ - name: "translateWithContext", - args: { - translationType: "detailed", - sourceLanguage: "auto", - targetLanguage: this.languageTitle, - phrase: text, - context: context || "", - // For the server-side translation_requested analytics event. - userId: authentication.user?.id, - }, - }); + const data = await withAuthRetry(() => + functionProvider.run({ + name: "translateWithContext", + args: { + translationType: "detailed", + sourceLanguage: "auto", + targetLanguage: this.languageTitle, + phrase: text, + context: context || "", + // For the server-side translation_requested analytics event. + userId: authentication.user?.id, + }, + }) + ); // Add context and phrase to the result data.context = context; @@ -203,18 +208,20 @@ export class TranslateService { currentChunks?: Chunk[]; history?: { role: "user" | "assistant"; text: string }[]; }): Promise { - return functionProvider.run({ - name: "translationAdvice", - args: { - phrase: params.phrase, - context: params.context || "", - message: params.message, - currentChunks: params.currentChunks || [], - history: params.history || [], - sourceLanguage: "auto", - targetLanguage: this.languageTitle, - }, - }); + return withAuthRetry(() => + functionProvider.run({ + name: "translationAdvice", + args: { + phrase: params.phrase, + context: params.context || "", + message: params.message, + currentChunks: params.currentChunks || [], + history: params.history || [], + sourceLanguage: "auto", + targetLanguage: this.languageTitle, + }, + }) + ); } async translateByDictionaryapi(word: string) { diff --git a/src/plugins/modular-rest.ts b/src/plugins/modular-rest.ts index d6d07fa..9b9a84c 100644 --- a/src/plugins/modular-rest.ts +++ b/src/plugins/modular-rest.ts @@ -82,6 +82,10 @@ import { ref } from "vue"; import { useProfileStore } from "../stores/profile"; import { analytic } from "./mixpanel"; import { debug, error, log } from "../common/helper/log"; +import { + reauthAnonymously, + setSessionRecovery, +} from "../common/helper/auth-recovery"; GlobalOptions.set({ host: process.env.SUBTURTLE_API_URL || "", @@ -176,42 +180,15 @@ export async function loginWithLastSession() { } return true; }) - .finally(() => { + .finally(async () => { if (!authentication.isLogin) { debug("Login with last session failed, trying anonymous login"); - - authentication - .loginAsAnonymous() - .then(async () => { - debug( - "Subturtle Anonymous login succeded", - authentication.isLogin - ); - // Persist the anonymous token so subsequent mounts (other bundles - // on the same page, the popup, page reloads) reuse it instead of - // each calling /user/loginAnonymous and stranding the previous - // anonymous user — which the server then 412s on the next call. - // Writes to chrome.storage.sync (cross-context) and to this - // page's localStorage (modular-rest's own per-origin cache). - const token = authentication.getToken; - if (token) { - try { - await sendMessage(new StoreUserTokenMessage(token)); - } catch (err) { - error( - "Subturtle: persisting anonymous token to background failed", - err - ); - } - // if (typeof localStorage !== "undefined") { - // localStorage.setItem("token", token); - // } - } - updateIsLogin(); - }) - .catch((err) => { - console.error("Subturtle anonymous login failed", err); - }); + // Establish + persist an anonymous session (shared primitive), then + // refresh the reactive isLogin ref. This is the first-session + // bootstrap; the same reauthAnonymously also backs translate's + // mid-session token self-heal (see TranslateService.withAuthRetry). + const ok = await reauthAnonymously(); + if (ok) updateIsLogin(); } }); } @@ -233,3 +210,38 @@ export async function logout(sendAuthStatusToOtherParts = true) { await sendMessage(message); } } + +/** + * Session recovery used by withAuthRetry when a request fails on a dead/stale + * token (translate is the canonical caller). Always ends by establishing a + * fresh anonymous session so the retry has a usable token. + * + * For a REGISTERED user whose token died, it first tears the dead session down + * via logout(): resets the profile store, the reactive isLogin ref and the + * analytics identity, removes the stored token from the background, and + * broadcasts StoreUserTokenMessage(null) to every tab. (The cross-tab + * broadcast is a no-op from a content script — which has no chrome.tabs — but + * fires fully from the popup.) That cleanly downgrades them to anonymous across + * the whole extension instead of leaving a phantom logged-in UI behind. + * + * For an ANONYMOUS session (the common stale-anon-token case — `user` is null, + * since loginAsAnonymous never sets a user), the logout teardown is skipped: + * there's no registered identity to reset and no reason to broadcast a logout + * that would re-roll every other tab's anon session. A contained + * reauthAnonymously is enough. + * + * logout() alone is NOT enough — it leaves no token, so the retry would just + * fail again; reauthAnonymously() is what makes the session usable. + */ +export async function recoverSession(): Promise { + if (authentication.user?.type?.toLowerCase() === "user") { + await logout(); + } + return reauthAnonymously(); +} + +// Upgrade withAuthRetry's recovery from the bare reauthAnonymously default to +// the system-wide recoverSession above. modular-rest is imported by every +// bundle before any translate runs, so this override is always in effect in +// production; unit tests that don't load this plugin keep the bare default. +setSessionRecovery(recoverSession); diff --git a/tests/auth-anon-flow.test.ts b/tests/auth-anon-flow.test.ts index 16376dd..e94f1c3 100644 --- a/tests/auth-anon-flow.test.ts +++ b/tests/auth-anon-flow.test.ts @@ -313,6 +313,108 @@ describe("loginWithLastSession", () => { }); }); +describe("recoverSession (withAuthRetry's system-wide recovery)", () => { + let mod: typeof import("../src/plugins/modular-rest"); + + beforeEach(async () => { + setActivePinia(createPinia()); + auth.isLogin = false; + auth.user = null; + auth.getToken = null; + auth.loginAsAnonymous.mockReset(); + auth.logout.mockReset(); + auth.logout.mockImplementation(() => { + auth.isLogin = false; + auth.user = null; + auth.getToken = null; + }); + + profileStore.logout.mockReset(); + analyticMock.reset.mockReset(); + + getSendMessageMock().mockReset(); + stubBackgroundLoginStatus(null); + (globalThis as any).chrome.tabs.sendMessage.mockReset?.(); + + vi.resetModules(); + mod = await import("../src/plugins/modular-rest"); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("tears a registered session down (logout) then re-establishes anonymous", async () => { + // A registered user whose token just died: must be fully torn down so no + // phantom logged-in state lingers, then downgraded to a fresh anon session. + auth.isLogin = true; + auth.user = { id: "user-1", type: "user", email: "x@y.z" }; + auth.getToken = "dead-token"; + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.user = null; + auth.getToken = "fresh-anon"; + return { token: "fresh-anon" }; + }); + + const ok = await mod.recoverSession(); + await new Promise((r) => setTimeout(r, 0)); + + // logout() teardown ran first... + expect(auth.logout).toHaveBeenCalled(); + expect(profileStore.logout).toHaveBeenCalled(); + expect(analyticMock.reset).toHaveBeenCalled(); + // ...then a fresh anonymous session was established and persisted. + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + expect(ok).toBe(true); + const last = storeTokenCalls().at(-1)?.[0] as StoreUserTokenMessage; + expect(last.token).toBe("fresh-anon"); + // A clear-broadcast (null) preceded the fresh-token write. + expect(storeTokenCalls().some(([m]) => (m as any).token === null)).toBe( + true + ); + }); + + it("for an anonymous session, skips the logout teardown and just re-auths", async () => { + // The common stale-anon-token case: `user` is null. No registered identity + // to reset, so no logout broadcast that would re-roll other tabs' sessions. + auth.isLogin = false; + auth.user = null; + auth.getToken = null; + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.getToken = "fresh-anon"; + return { token: "fresh-anon" }; + }); + + const ok = await mod.recoverSession(); + await new Promise((r) => setTimeout(r, 0)); + + expect(auth.logout).not.toHaveBeenCalled(); + expect(profileStore.logout).not.toHaveBeenCalled(); + expect(analyticMock.reset).not.toHaveBeenCalled(); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + expect(ok).toBe(true); + // No clear-broadcast — only the fresh-token write. + expect(storeTokenCalls().some(([m]) => (m as any).token === null)).toBe( + false + ); + const last = storeTokenCalls().at(-1)?.[0] as StoreUserTokenMessage; + expect(last.token).toBe("fresh-anon"); + }); + + it("returns false when the anonymous re-auth fails after a registered teardown", async () => { + auth.isLogin = true; + auth.user = { id: "user-1", type: "user" }; + auth.loginAsAnonymous.mockRejectedValue(new Error("network down")); + + const ok = await mod.recoverSession(); + + expect(auth.logout).toHaveBeenCalled(); + expect(ok).toBe(false); + }); +}); + describe("chrome.runtime.onMessage StoreUserTokenMessage listener", () => { beforeEach(async () => { setActivePinia(createPinia()); diff --git a/tests/auth-recovery.test.ts b/tests/auth-recovery.test.ts new file mode 100644 index 0000000..eb1ced2 --- /dev/null +++ b/tests/auth-recovery.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { MESSAGE_TYPE } from "../src/common/types/messaging"; + +// Controllable @modular-rest/client mock — the recovery primitive calls +// authentication.loginAsAnonymous and reads isLogin/getToken. Mocking at this +// boundary (rather than mocking reauthAnonymously) lets us exercise the REAL +// withAuthRetry + reauthAnonymously together, which matters now that both live +// in one module and call each other intra-module. +// vi.hoisted so `auth` exists before the hoisted vi.mock factory runs (a +// top-level import of auth-recovery pulls @modular-rest/client in eagerly). +const auth = vi.hoisted(() => ({ + isLogin: false, + getToken: null as string | null, + loginAsAnonymous: vi.fn(), +})); + +vi.mock("@modular-rest/client", () => ({ authentication: auth })); + +import { + isAuthError, + reauthAnonymously, + withAuthRetry, + setSessionRecovery, +} from "../src/common/helper/auth-recovery"; + +function sendMessageMock() { + return (globalThis as any).chrome.runtime.sendMessage as ReturnType< + typeof vi.fn + >; +} + +function storeTokenCalls() { + return sendMessageMock().mock.calls.filter( + ([m]) => m && (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN + ); +} + +// --------------------------------------------------------------------------- + +describe("isAuthError", () => { + it("matches the raw string body functionProvider.run re-throws", () => { + // The exact body the server returned in the reported bug screenshot. + expect(isAuthError("User not found")).toBe(true); + }); + + it("matches the { hasError, error } wrapper dataProvider lets through", () => { + expect(isAuthError({ hasError: true, error: "User not found" })).toBe(true); + }); + + it("matches a nested error object", () => { + expect(isAuthError({ error: { message: "Unauthorized" } })).toBe(true); + }); + + it("matches the verify/token error shape { status, e }", () => { + // /verify/token surfaces the jwt reason under `e` as a real string. + expect(isAuthError({ status: "error", e: "jwt expired" })).toBe(true); + }); + + // The literal bodies the modular-rest server actually emits on auth-gated + // routes — verified against server-ts source. See AUTH_ERROR_PATTERNS. + it.each([ + "User not found", // 412 — token valid but user purged + "authentication is required", // 401 — missing/empty Authorization header + "access denied", // 403 — lacks permission + "Precondition Failed", // 412 — invalid/expired/wrong-sig token (reason swallowed) + "jwt expired", + "invalid signature", + "jwt malformed", + "invalid token", + "Unauthorized", // defensive (proxies/gateways) + "Forbidden", // defensive + ])("matches real auth body: %s", (msg) => { + expect(isAuthError(msg)).toBe(true); + }); + + // Real NON-auth bodies the same server emits — must not trigger a needless + // anonymous re-auth (which would silently downgrade a registered user). + it.each([ + "network error", + "Request failed with status code 500", + "Internal Server Error", // data-provider 500 (db/collection error) + "Freemium limit reached", + "not enough credit", + "timeout of 10000ms exceeded", + ])("does NOT match non-auth error: %s", (msg) => { + expect(isAuthError(msg)).toBe(false); + }); + + it("does NOT match a field-validation 412 body (auth already passed)", () => { + // data-provider/function validation failure: a JSON STRING under `error`. + expect(isAuthError('{"status":"error","error":["query"]}')).toBe(false); + }); + + it("does NOT match a login failure whose message was lost to {e:{}}", () => { + // /user/login wraps a bare Error → JSON.stringify drops it to {}. We must + // NOT silently retry an explicit login as anonymous. + expect(isAuthError({ status: "error", e: {} })).toBe(false); + }); + + it("is case-insensitive", () => { + expect(isAuthError("USER NOT FOUND")).toBe(true); + expect(isAuthError("PRECONDITION FAILED")).toBe(true); + }); + + it("returns false for empty / nullish input", () => { + expect(isAuthError(null)).toBe(false); + expect(isAuthError(undefined)).toBe(false); + expect(isAuthError("")).toBe(false); + expect(isAuthError({})).toBe(false); + }); + + it("does not throw on a self-referential object", () => { + const circular: Record = {}; + circular.error = circular; + expect(() => isAuthError(circular)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("reauthAnonymously", () => { + beforeEach(() => { + auth.isLogin = false; + auth.getToken = null; + auth.loginAsAnonymous.mockReset(); + sendMessageMock().mockClear(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("logs in anonymously, persists the fresh token, and returns true", async () => { + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.getToken = "anon-token"; + }); + + await expect(reauthAnonymously()).resolves.toBe(true); + + const calls = storeTokenCalls(); + expect(calls.length).toBe(1); + expect((calls.at(-1)![0] as any).token).toBe("anon-token"); + }); + + it("returns false and logs (does not throw) when anonymous login rejects", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + auth.loginAsAnonymous.mockRejectedValue(new Error("network down")); + + await expect(reauthAnonymously()).resolves.toBe(false); + expect(errSpy).toHaveBeenCalledWith( + "Subturtle anonymous login failed", + expect.any(Error) + ); + expect(storeTokenCalls().length).toBe(0); + }); + + it("coalesces concurrent re-auths into a single loginAnonymous", async () => { + // Hold loginAnonymous pending so all three calls overlap before it settles. + let resolveLogin!: (v: unknown) => void; + auth.loginAsAnonymous.mockImplementation( + () => new Promise((r) => (resolveLogin = r)) + ); + + const calls = [reauthAnonymously(), reauthAnonymously(), reauthAnonymously()]; + // All three joined the same in-flight login — no thundering herd. + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + + auth.isLogin = true; + auth.getToken = "anon-token"; + resolveLogin({ token: "anon-token" }); + + expect(await Promise.all(calls)).toEqual([true, true, true]); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + // Only ONE anon user minted → only one token persisted. + expect(storeTokenCalls().length).toBe(1); + }); + + it("starts a fresh login for a re-auth after the previous one settled", async () => { + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.getToken = "anon-token"; + }); + + await reauthAnonymously(); + await reauthAnonymously(); + + // The in-flight guard reset between the two sequential calls. + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- + +describe("withAuthRetry", () => { + beforeEach(() => { + // Default recovery is reauthAnonymously (modular-rest is not loaded here, so + // its setSessionRecovery override never ran). Pin it defensively in case an + // earlier test injected a custom recovery. + setSessionRecovery(reauthAnonymously); + + auth.isLogin = false; + auth.getToken = "anon-token"; + auth.loginAsAnonymous.mockReset(); + // Default: recovery succeeds (produces a usable session). + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + }); + sendMessageMock().mockClear(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setSessionRecovery(reauthAnonymously); + }); + + it("returns the result and never recovers when the call succeeds", async () => { + const run = vi.fn().mockResolvedValue("ok"); + + await expect(withAuthRetry(run)).resolves.toBe("ok"); + + expect(run).toHaveBeenCalledTimes(1); + expect(auth.loginAsAnonymous).not.toHaveBeenCalled(); + }); + + it("recovers an anonymous session and retries once on an auth error", async () => { + const run = vi + .fn() + .mockRejectedValueOnce("User not found") + .mockResolvedValueOnce("ok"); + + await expect(withAuthRetry(run)).resolves.toBe("ok"); + + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("recognizes the { hasError, error } wrapper dataProvider lets through", async () => { + const run = vi + .fn() + .mockRejectedValueOnce({ hasError: true, error: "Unauthorized" }) + .mockResolvedValueOnce(42); + + await expect(withAuthRetry(run)).resolves.toBe(42); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry non-auth errors", async () => { + const run = vi.fn().mockRejectedValue(new Error("network")); + + await expect(withAuthRetry(run)).rejects.toThrow("network"); + + expect(auth.loginAsAnonymous).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("surfaces the original error when recovery produces no session", async () => { + // loginAsAnonymous resolves but leaves isLogin false → recovery failed. + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = false; + }); + const run = vi.fn().mockRejectedValue("User not found"); + + await expect(withAuthRetry(run)).rejects.toBe("User not found"); + + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("retries at most once — a still-failing token surfaces the error", async () => { + const run = vi.fn().mockRejectedValue("User not found"); + + await expect(withAuthRetry(run)).rejects.toBe("User not found"); + + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + // Initial attempt + exactly one retry, then it gives up (no loop). + expect(run).toHaveBeenCalledTimes(2); + }); + + it("is generic over the thunk's return type", async () => { + const obj = { phrase: "x", chunks: [] }; + const run = vi.fn().mockResolvedValue(obj); + + await expect(withAuthRetry(run)).resolves.toBe(obj); + }); + + it("delegates recovery to the injected strategy (setSessionRecovery)", async () => { + // In production modular-rest installs the system-wide recoverSession + // (logout + reauth). Verify withAuthRetry uses whatever was injected, + // not the bare reauthAnonymously default. + const customRecover = vi.fn().mockResolvedValue(true); + setSessionRecovery(customRecover); + + const run = vi + .fn() + .mockRejectedValueOnce("User not found") + .mockResolvedValueOnce("ok"); + + await expect(withAuthRetry(run)).resolves.toBe("ok"); + + expect(customRecover).toHaveBeenCalledTimes(1); + expect(auth.loginAsAnonymous).not.toHaveBeenCalled(); // default bypassed + expect(run).toHaveBeenCalledTimes(2); + }); + + it("coalesces recovery across concurrent failing calls — one loginAnonymous", async () => { + // The reported bug: a burst of failing requests each spawning its own + // /user/loginAnonymous. Three independent calls fail with an auth error at + // once; they must share ONE recovery, then each retry its own call. + const mkRun = (ok: string) => + vi.fn().mockRejectedValueOnce("User not found").mockResolvedValueOnce(ok); + const runA = mkRun("a"); + const runB = mkRun("b"); + const runC = mkRun("c"); + + const results = await Promise.all([ + withAuthRetry(runA), + withAuthRetry(runB), + withAuthRetry(runC), + ]); + + expect(results).toEqual(["a", "b", "c"]); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); // not 3 + expect(runA).toHaveBeenCalledTimes(2); + expect(runB).toHaveBeenCalledTimes(2); + expect(runC).toHaveBeenCalledTimes(2); + }); + + it("recovers again for a failure that arrives after the burst settled", async () => { + const run1 = vi + .fn() + .mockRejectedValueOnce("User not found") + .mockResolvedValueOnce("first"); + await expect(withAuthRetry(run1)).resolves.toBe("first"); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + + // A later, separate failure must be able to trigger a fresh recovery. + const run2 = vi + .fn() + .mockRejectedValueOnce("User not found") + .mockResolvedValueOnce("second"); + await expect(withAuthRetry(run2)).resolves.toBe("second"); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/translate.service.test.ts b/tests/translate.service.test.ts index 2904b10..b9234a3 100644 --- a/tests/translate.service.test.ts +++ b/tests/translate.service.test.ts @@ -1,13 +1,25 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { setActivePinia, createPinia } from "pinia"; +// Controllable @modular-rest/client mock. translate.service reads +// authentication.user?.id for analytics; withAuthRetry's recovery path (in +// helper/auth-recovery) calls authentication.loginAsAnonymous and reads +// isLogin/getToken. Exposing those lets the self-heal integration tests drive +// a successful recovery without the network — and, because withAuthRetry now +// calls reauthAnonymously intra-module, mocking at this boundary is the only +// way to control recovery from a consumer test. +// vi.hoisted so `auth` exists before the hoisted vi.mock factory runs (a +// top-level import of translate.service pulls @modular-rest/client in eagerly). +const auth = vi.hoisted(() => ({ + user: { id: "test-user-id" } as { id: string } | null, + isLogin: true, + getToken: "anon-token" as string | null, + loginAsAnonymous: vi.fn(), +})); + vi.mock("@modular-rest/client", () => ({ - functionProvider: { - run: vi.fn(), - }, - // translate.service reads authentication.user?.id to attach a userId for the - // server-side translation_requested event. - authentication: { user: { id: "test-user-id" } }, + functionProvider: { run: vi.fn() }, + authentication: auth, })); import { TranslateService } from "../src/common/services/translate.service"; @@ -34,9 +46,14 @@ describe("TranslateService cache", () => { runMock.mockReset(); runMock.mockResolvedValue("translated"); + auth.loginAsAnonymous.mockClear(); + auth.isLogin = true; + auth.getToken = "anon-token"; + // fetchSimpleTranslation logs to console.error on failure paths; // silence to keep test output clean. vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { @@ -100,3 +117,60 @@ describe("TranslateService cache", () => { expect(runMock).toHaveBeenCalledTimes(2); }); }); + +// Regression net for the "Translation failed / User not found" report: a stale +// anonymous token in chrome.storage.sync must self-heal — translation wraps its +// calls in withAuthRetry, which recovers an anonymous session and retries once. +// The retry POLICY itself is covered in tests/auth-recovery.test.ts; here we +// only assert translation is wired to it and the recovered result still caches. +describe("TranslateService auth recovery (integration)", () => { + let svc: TranslateService; + + beforeEach(() => { + setActivePinia(createPinia()); + useSettingsStore().language = "en"; + (TranslateService.instance as any).translationCache = {}; + svc = TranslateService.instance; + + runMock.mockReset(); + auth.loginAsAnonymous.mockClear(); + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + }); + auth.isLogin = true; + auth.getToken = "anon-token"; + + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("self-heals on 'User not found' and caches the retried result", async () => { + runMock.mockRejectedValueOnce("User not found").mockResolvedValueOnce("ok"); + + const first = await svc.fetchSimpleTranslation("hello", "ctx"); + const second = await svc.fetchSimpleTranslation("hello", "ctx"); + + expect(first).toBe("ok"); + expect(second).toBe("ok"); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + // 2 from the recover+retry of the first call, none from the cached second. + expect(runMock).toHaveBeenCalledTimes(2); + }); + + it("self-heals on the detailed translation path too", async () => { + runMock + .mockRejectedValueOnce({ hasError: true, error: "User not found" }) + .mockResolvedValueOnce({ phrase: "", context: "" }); + + const result = await svc.fetchDetailedTranslation("hello", "ctx"); + + expect(result).toBeTruthy(); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledTimes(2); + }); +});