From 2b89736bcb6664f2ecb82ba902ef1bb5a83dcc61 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 8 Jun 2026 16:13:15 +0300 Subject: [PATCH 1/6] fix(console-crane): isolate modal from host-page color/flex CSS leaks studio.youtube.com (a Polymer app) leaks `html{color:#fff}` and `flex:1 0 1e-9px` into our scoped UI, washing out the modal header icons and inflating the header
so content was pushed down with large gaps. Pin a per-theme base text color and restore the default `flex:0 1 auto` on scoped flow elements in tailwind.css; add an e2e host-style-isolation regression net. Ref #86exw6kme Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tailwind.css | 56 ++++++++++++++- tests/e2e/host-style-isolation.spec.ts | 96 ++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/host-style-isolation.spec.ts 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/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); + }); +}); From c76121edcd82654918ca1762df47d76eafbe4219 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 8 Jun 2026 13:23:26 +0000 Subject: [PATCH 2/6] chore(release): 1.14.0-dev.2 [skip ci] # [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) --- CHANGELOG-DEV.md | 7 +++++++ package.json | 2 +- static/manifest.json | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 074a70f..f706c19 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,10 @@ +# [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 4114c0d..cbf3873 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.14.0-dev.1", + "version": "1.14.0-dev.2", "private": true, "scripts": { "dev": "webpack --watch", diff --git a/static/manifest.json b/static/manifest.json index d481798..5c766e1 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.1", + "version": "1.14.0.2", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -80,5 +80,5 @@ ] } ], - "version_name": "1.14.0-dev.1" + "version_name": "1.14.0-dev.2" } From cf057a095e85ac7d55824cafe5f06eeb2568d06a Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 8 Jun 2026 17:17:55 +0300 Subject: [PATCH 3/6] fix(console-crane): align bundle selector height with the Save button The pilotui Select renders its visible trigger button at the bottom of a 4-level wrapper chain, two links of which are display:block, so the bundle selector field rendered a few px shorter than the adjacent lg Save button in SaveWordSectionV2. Lay the SelectPhraseBundleV2 root out as a flex row and stretch every link of the chain so the trigger fills the row height and lines up with Save, in both the plain and freemium (InputGroup) save layouts. (Also carries an in-progress justify-center tweak + reformatting in the same component.) Ref #86exw6kme Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/SelectPhraseBundleV2.vue | 128 +++++++++++------- 1 file changed, 81 insertions(+), 47 deletions(-) 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({ From 16a6feb5b47f6ffbb951ff7658e926c237f0d572 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 8 Jun 2026 14:23:37 +0000 Subject: [PATCH 4/6] chore(release): 1.14.1-dev.1 [skip ci] ## [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) --- CHANGELOG-DEV.md | 8 ++++++++ package.json | 4 ++-- static/manifest.json | 6 +++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index f706c19..1e65c90 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,11 @@ +## [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) diff --git a/package.json b/package.json index 643ebc2..b930655 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.14.0-dev.2", + "version": "1.14.1-dev.1", "private": true, "scripts": { "dev": "webpack --watch", @@ -60,4 +60,4 @@ "vue": "3.5.17", "vue-router": "4.5.1" } -} \ No newline at end of file +} diff --git a/static/manifest.json b/static/manifest.json index a2ee84f..7c7321d 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.2", + "version": "1.14.1.1", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -80,5 +80,5 @@ ] } ], - "version_name": "1.14.0-dev.2" -} \ No newline at end of file + "version_name": "1.14.1-dev.1" +} From 6ef3766e482c0c97476b4d8aac59fe2f92f7376b Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 8 Jun 2026 19:04:31 +0300 Subject: [PATCH 5/6] fix: self-heal stale auth token on translate instead of failing A purged/invalid token in chrome.storage.sync made every translation fail with "User not found" (and other token-dependent calls 412) until a full reload. Requests now detect auth-shaped failures, recover the session, and retry once. - isAuthError: detect token failures from the response body (the client discards the HTTP status). Patterns are grounded in the actual modular-rest server output, including the swallowed "Precondition Failed" body for invalid/expired/wrong-signature tokens. - withAuthRetry: reusable wrapper that recovers + retries once on an auth error; wired into TranslateService's three function/run calls. - recoverSession: tears a dead registered session down system-wide via logout() (profile/isLogin/analytics reset + broadcast) then re-auths anonymous; anonymous sessions just re-auth (contained). - single-flight recovery: concurrent failures share ONE loginAnonymous instead of each minting and stranding an anonymous user. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/common/helper/auth-recovery.ts | 267 +++++++++++++++++ src/common/services/translate.service.ts | 79 ++--- src/plugins/modular-rest.ts | 80 +++--- tests/auth-anon-flow.test.ts | 102 +++++++ tests/auth-recovery.test.ts | 351 +++++++++++++++++++++++ tests/translate.service.test.ts | 86 +++++- 6 files changed, 889 insertions(+), 76 deletions(-) create mode 100644 src/common/helper/auth-recovery.ts create mode 100644 tests/auth-recovery.test.ts 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); + }); +}); From 1c9bc42652d6f3d88a01e1e87da64bb94b2433c1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 8 Jun 2026 16:16:37 +0000 Subject: [PATCH 6/6] chore(release): 1.14.1-dev.2 [skip ci] ## [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)) --- CHANGELOG-DEV.md | 7 +++++++ package.json | 2 +- static/manifest.json | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 1e65c90..9044c6a 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,10 @@ +## [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) diff --git a/package.json b/package.json index b930655..9c2a925 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.14.1-dev.1", + "version": "1.14.1-dev.2", "private": true, "scripts": { "dev": "webpack --watch", diff --git a/static/manifest.json b/static/manifest.json index 7c7321d..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.1.1", + "version": "1.14.1.2", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -80,5 +80,5 @@ ] } ], - "version_name": "1.14.1-dev.1" + "version_name": "1.14.1-dev.2" }