diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 074a70f..9044c6a 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,25 @@ +## [1.14.1-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.1-dev.1...v1.14.1-dev.2) (2026-06-08) + + +### Bug Fixes + +* self-heal stale auth token on translate instead of failing ([6ef3766](https://github.com/codebridger/subturtle-extension-apps/commit/6ef3766e482c0c97476b4d8aac59fe2f92f7376b)) + +## [1.14.1-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.0...v1.14.1-dev.1) (2026-06-08) + + +### Bug Fixes + +* **console-crane:** align bundle selector height with the Save button ([cf057a0](https://github.com/codebridger/subturtle-extension-apps/commit/cf057a095e85ac7d55824cafe5f06eeb2568d06a)), closes [#86exw6kme](https://github.com/codebridger/subturtle-extension-apps/issues/86exw6kme) +* **console-crane:** isolate modal from host-page color/flex CSS leaks ([2b89736](https://github.com/codebridger/subturtle-extension-apps/commit/2b89736bcb6664f2ecb82ba902ef1bb5a83dcc61)), closes [#86exw6kme](https://github.com/codebridger/subturtle-extension-apps/issues/86exw6kme) + +# [1.14.0-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.0-dev.1...v1.14.0-dev.2) (2026-06-08) + + +### Bug Fixes + +* **console-crane:** isolate modal from host-page color/flex CSS leaks ([2b89736](https://github.com/codebridger/subturtle-extension-apps/commit/2b89736bcb6664f2ecb82ba902ef1bb5a83dcc61)), closes [#86exw6kme](https://github.com/codebridger/subturtle-extension-apps/issues/86exw6kme) + # [1.14.0-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.13.0...v1.14.0-dev.1) (2026-06-08) diff --git a/package.json b/package.json index 0e3f5f6..9c2a925 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.14.0", + "version": "1.14.1-dev.2", "private": true, "scripts": { "dev": "webpack --watch", 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/console-crane/components/SelectPhraseBundleV2.vue b/src/console-crane/components/SelectPhraseBundleV2.vue index 8a0d682..198564d 100644 --- a/src/console-crane/components/SelectPhraseBundleV2.vue +++ b/src/console-crane/components/SelectPhraseBundleV2.vue @@ -2,53 +2,54 @@
-
Suggested @@ -289,5 +292,36 @@ defineExpose({ 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/src/tailwind.css b/src/tailwind.css index bd6213e..8865209 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -1,3 +1,57 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +/* ── Host-page isolation hardening ────────────────────────────────────────── + postcss-prefix-selector scopes OUR selectors to `.subturtle-scope`, but + scoping does nothing to stop the *host page's* element/universal rules from + cascading INTO our elements. Two such leaks broke the ConsoleCrane header on + dark host pages — most visibly studio.youtube.com (ClickUp 86exw6kme), whose + header rendered washed-out and pushed down with big gaps below. + + The rules below carry an explicit `.subturtle-scope` so the prefixer's + idempotent guard leaves them as-is. They sit after `@tailwind utilities` and + are deliberately low-specificity (one class + one tag = 0,1,1) so our own + `text-*` / `flex-*` / `shrink-*` utilities (0,2,0) always win — these only + take effect where we set nothing, which is exactly where a host value would + otherwise leak in. + + 1) Inherited text color. Tailwind preflight sets no base `color`, so an + un-colored element inherits the host's. studio sets `html { color:#fff }`, + which painted our header IconButtons (pilotui sets a text color only in + dark mode) near-white on the white light-theme modal. Pin a base color + per theme so the extension UI never inherits the host's. */ +.subturtle-scope { + color: #111827; /* gray-900 — light-theme base text */ +} +.subturtle-scope.dark { + color: #f3f4f6; /* gray-100 — dark-theme base text */ +} + +/* 2) Inherited flex-grow. studio is a Polymer app whose iron-flex-layout sets + `flex: 1 0 1e-9px` on generic elements; it leaked onto our flex children + (the header
, the word-detail content column and its buttons), + growing them to fill the modal — a tall empty header band and large gaps + between cards. Restore the CSS-default `flex: 0 1 auto`; intentional + growth/non-shrink still comes from our `flex-1` / `shrink-0` utilities. */ +.subturtle-scope div, +.subturtle-scope section, +.subturtle-scope header, +.subturtle-scope footer, +.subturtle-scope main, +.subturtle-scope article, +.subturtle-scope aside, +.subturtle-scope nav, +.subturtle-scope form, +.subturtle-scope fieldset, +.subturtle-scope ul, +.subturtle-scope ol, +.subturtle-scope li, +.subturtle-scope dl, +.subturtle-scope p, +.subturtle-scope span, +.subturtle-scope a, +.subturtle-scope button, +.subturtle-scope label { + flex: 0 1 auto; +} diff --git a/static/manifest.json b/static/manifest.json index 25b6202..ebbe233 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "name": "Subturtle", "description": "Turn video subtitles into English lessons. Learn new vocabulary in context as you watch on YouTube and Netflix.", - "version": "1.14.0", + "version": "1.14.1.2", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -79,5 +79,6 @@ "" ] } - ] + ], + "version_name": "1.14.1-dev.2" } 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/e2e/host-style-isolation.spec.ts b/tests/e2e/host-style-isolation.spec.ts new file mode 100644 index 0000000..17c3634 --- /dev/null +++ b/tests/e2e/host-style-isolation.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from "./extension-fixture"; + +// Regression net for ClickUp 86exw6kme — the ConsoleCrane header rendered +// broken on dark host pages (studio.youtube.com). Two host-CSS leaks were the +// cause; scoping confines OUR selectors but does not stop the host's +// element/universal rules from cascading INTO our elements: +// +// 1. Inherited text color — the host sets `html { color: #fff }`. With no +// base color of our own, the header IconButtons (pilotui only colors them +// in dark mode) inherited white and vanished on the white light-theme +// modal. +// 2. Inherited flex-grow — the host (a Polymer app) sets `flex: 1 0 1e-9px` +// on generic elements, which leaked onto our header
and grew it +// to fill half the modal, pushing content down. +// +// src/tailwind.css pins a per-theme base color and restores the default +// `flex: 0 1 auto` on our scoped flow elements. This test reproduces the host +// conditions and asserts both leaks are neutralized. + +test.describe("host-page style isolation (ConsoleCrane header)", () => { + test("dark host page does not wash out or inflate the header", async ({ + context, + serviceWorker, + }) => { + // Light theme is the failing case: white modal + inherited light text. + await serviceWorker.evaluate(async () => { + await chrome.storage.local.set({ + settings: { theme: "light", language: "en", nibbleDisabledDomains: [] }, + }); + }); + + const page = await context.newPage(); + await page.goto("/index.html"); + + // Simulate a dark Polymer host: light text + a broad flex-grow leak, + // exactly the two rules that broke the header on studio.youtube.com. + await page.addStyleTag({ + content: ` + html, body { background:#0f0f0f !important; color:#ffffff !important; } + div, section, header, footer, main, article, aside, nav, + ul, ol, li, p, span, a, button, label { flex: 1 0 1e-9px; } + `, + }); + + await expect(page.locator("#subturtle-console-crane-root")).toBeAttached({ + timeout: 10_000, + }); + + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("subturtle:console-crane:open", { + detail: { + page: "word-detail", + params: { word: "Channel analytics" }, + active: true, + }, + }) + ); + }); + + const modalSection = page.locator( + "#subturtle-console-crane section.absolute.rounded-xl" + ); + await expect(modalSection).toBeVisible({ timeout: 5_000 }); + await page.waitForTimeout(300); + + const probe = await page.evaluate(() => { + const sec = document.querySelector( + "#subturtle-console-crane section.absolute.rounded-xl" + ) as HTMLElement; + const flexCol = sec.querySelector(":scope > div > div") as HTMLElement; + const header = flexCol.children[0] as HTMLElement; + // First IconButton (the cog) renders as a
; the mask icon inside it + // paints via `color` (currentColor). Read the button's resolved color. + const cog = header.querySelector(":scope > div") as HTMLElement; + const parseRgb = (s: string) => + (s.match(/\d+/g) || []).slice(0, 3).map(Number); + return { + headerHeight: Math.round(header.getBoundingClientRect().height), + cogColor: getComputedStyle(cog).color, + cogRgb: parseRgb(getComputedStyle(cog).color), + }; + }); + + // Leak 2: the header must size to its content, not grow to fill the modal. + expect(probe.headerHeight).toBeLessThan(80); + + // Leak 1: the cog icon must use our dark base color, not the inherited + // host white. Assert it's a dark color (every channel well below 128). + const [r, g, b] = probe.cogRgb; + expect( + Math.max(r, g, b), + `cog icon color should be dark, got ${probe.cogColor}` + ).toBeLessThan(100); + }); +}); 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); + }); +});