diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 871f50c..341f7f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,8 @@ concurrency: jobs: verify: name: Verify (lint, unit, build, e2e) + # Prevent duplicate runs on internal PRs from branches that already trigger a push event + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository || !contains(fromJSON('["main", "dev"]'), github.event.pull_request.head.ref) runs-on: ubuntu-latest steps: - name: Checkout diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 0cbd677..ffbe7c6 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,10 @@ +## [1.11.1-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.11.0...v1.11.1-dev.1) (2026-05-06) + + +### Bug Fixes + +* [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exgqfpu skip token writes to host LS on dashboard origins ([838451e](https://github.com/codebridger/subturtle-extension-apps/commit/838451e9ad10d3a3755691b4ca586a0d3815a008)), closes [#86exgqfpu](https://github.com/codebridger/subturtle-extension-apps/issues/86exgqfpu) + # [1.11.0-dev.4](https://github.com/codebridger/subturtle-extension-apps/compare/v1.11.0-dev.3...v1.11.0-dev.4) (2026-05-06) diff --git a/package.json b/package.json index 1ca9153..d22715d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subturtle-extension", - "version": "1.11.1", + "version": "1.11.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/src/common/helper/log.ts b/src/common/helper/log.ts index 48fd646..cf4bf82 100644 --- a/src/common/helper/log.ts +++ b/src/common/helper/log.ts @@ -1,7 +1,17 @@ -export function log(...arg) { - console.log("Subturtle:", ...arg); +export function log(...arg: any[]) { + console.log("[Subturtle]", ...arg); } -export function error(...arg) { - console.error("Subturtle:", ...arg); +export function warn(...arg: any[]) { + console.warn("[Subturtle]", ...arg); +} + +export function debug(...arg: any[]) { + if (process.env.NODE_ENV !== "production") { + console.debug("[Subturtle]", ...arg); + } +} + +export function error(...arg: any[]) { + console.error("[Subturtle]", ...arg); } diff --git a/src/common/helper/massage.ts b/src/common/helper/massage.ts index 7d5f67e..513c786 100644 --- a/src/common/helper/massage.ts +++ b/src/common/helper/massage.ts @@ -1,4 +1,5 @@ import { BaseMessage, LoginStatusResponse } from "../types/messaging"; +import { debug, log } from "./log"; /** * Send message to background @@ -10,6 +11,7 @@ export async function sendMessage( ) { return new Promise((resolve, reject) => { try { + debug("Sending message to background:", message); chrome.runtime.sendMessage(message, (response) => { if (chrome.runtime.lastError) { // Chrome runtime errors should be properly formatted with message @@ -22,6 +24,7 @@ export async function sendMessage( // Handle case where response is undefined/null reject(new Error("No response received")); } else { + debug("Received response:", response); resolve(response); } }); diff --git a/src/common/types/messaging.ts b/src/common/types/messaging.ts index 8ebe3e7..6d0b19a 100644 --- a/src/common/types/messaging.ts +++ b/src/common/types/messaging.ts @@ -20,8 +20,8 @@ export type SettingsObject = { export class BaseMessage { type!: string; - static is(message: any) {} - static checkResponse(response: any) {} + static is(message: any) { } + static checkResponse(response: any) { } } export class GetLoginStatusMessage implements BaseMessage { @@ -35,6 +35,9 @@ export class GetLoginStatusMessage implements BaseMessage { return message.type === MESSAGE_TYPE.GET_LOGIN_STATUS; } + /** + * To check if the message has an expected token in response. + */ static checkResponse(response: any): response is LoginStatusResponse { return response && response.status && response.token; } diff --git a/src/plugins/modular-rest.ts b/src/plugins/modular-rest.ts index acdb4e0..1ec2d6f 100644 --- a/src/plugins/modular-rest.ts +++ b/src/plugins/modular-rest.ts @@ -49,6 +49,7 @@ import { import { ref } from "vue"; import { useProfileStore } from "../stores/profile"; import { analytic } from "./mixpanel"; +import { debug, error, log } from "../common/helper/log"; GlobalOptions.set({ host: process.env.SUBTURTLE_API_URL || "", @@ -61,6 +62,7 @@ export { } from "@modular-rest/client"; export const isLogin = ref(false); + function updateIsLogin() { const loginInfo = authentication.isLogin && authentication.user?.type.toLowerCase() == "user"; @@ -79,8 +81,8 @@ function updateIsLogin() { return true; }) - .catch((error) => { - console.error("Error bootstrapping profile store:", error); + .catch((errorDetail) => { + error("Error bootstrapping profile store:", errorDetail); return false; }); } @@ -111,11 +113,20 @@ export async function loginWithLastSession() { const user = await authentication.loginWithToken( res.token as string, - true + // token will be stored on background service, so we don't need to store it here + false ); - return user; + + debug("retrieved user from last login: ", user); + + if (user.type == "anonymous") { + authentication.logout(); + return false; + } else { + return updateIsLogin(); + } + }) - .then((_user) => updateIsLogin()) .then(async (_isRegisteredUser) => { // updateIsLogin's truthy result means "registered user with a real // account". Anonymous users return false here even though they hold a @@ -126,19 +137,20 @@ export async function loginWithLastSession() { // session, clearing chrome.storage.sync and broadcasting null to every // tab — and the next translate from any content script then 412s // because its Authorization header is empty. - if (!authentication.isLogin) { + if (!_isRegisteredUser && !authentication.isLogin) { await logout(); return false; } return true; }) - .finally(() => { if (!authentication.isLogin) { + debug("Login with last session failed, trying anonymous login"); + authentication .loginAsAnonymous() .then(async () => { - console.log( + debug( "Subturtle Anonymous login succeded", authentication.isLogin ); @@ -153,14 +165,14 @@ export async function loginWithLastSession() { try { await sendMessage(new StoreUserTokenMessage(token)); } catch (err) { - console.warn( + error( "Subturtle: persisting anonymous token to background failed", err ); } - if (typeof localStorage !== "undefined") { - localStorage.setItem("token", token); - } + // if (typeof localStorage !== "undefined") { + // localStorage.setItem("token", token); + // } } updateIsLogin(); }) diff --git a/src/trusted-types-polyfill.ts b/src/trusted-types-polyfill.ts index 044269e..61be903 100644 --- a/src/trusted-types-polyfill.ts +++ b/src/trusted-types-polyfill.ts @@ -17,10 +17,12 @@ * when the extension needs to run on a host that blocks Vue's policy. */ +import { debug } from "./common/helper/log"; + const trustedTypes = (window as any).trustedTypes; if (trustedTypes && trustedTypes.createPolicy) { - console.log("[Subturtle] Setting up Trusted Types workaround"); + debug("[Subturtle] Setting up Trusted Types workaround"); const originalCreatePolicy = trustedTypes.createPolicy.bind(trustedTypes); @@ -32,7 +34,7 @@ if (trustedTypes && trustedTypes.createPolicy) { trustedTypes.createPolicy = function (name: string, rules: any) { if (name === "vue" || name.startsWith("vue-")) { - console.log( + debug( `[Subturtle] Intercepted policy creation for "${name}", returning passthrough` ); return createPassthroughPolicy(); @@ -46,7 +48,7 @@ if (trustedTypes && trustedTypes.createPolicy) { } }; - console.log("[Subturtle] Trusted Types workaround installed"); + debug("[Subturtle] Trusted Types workaround installed"); } -export {}; +export { }; diff --git a/static/manifest.json b/static/manifest.json index 65a9138..ee99d6f 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.11.1", + "version": "1.11.1.1", "manifest_version": 3, "icons": { "128": "/assets/logo-128.png", @@ -79,5 +79,6 @@ "" ] } - ] -} + ], + "version_name": "1.11.1-dev.1" +} \ No newline at end of file diff --git a/tests/auth-anon-flow.test.ts b/tests/auth-anon-flow.test.ts index 92e4815..16376dd 100644 --- a/tests/auth-anon-flow.test.ts +++ b/tests/auth-anon-flow.test.ts @@ -31,37 +31,47 @@ vi.mock("@modular-rest/client", () => ({ functionProvider: { run: vi.fn() }, })); -// useProfileStore is only invoked inside updateIsLogin's registered-user -// branch and inside logout(); the anon flow doesn't hit those, but logout() is -// still called when the token truly fails to validate. Keep it as a no-op so -// it doesn't pull in the sibling dashboard-app type imports at module load. +// useProfileStore is invoked in updateIsLogin's registered-user branch and in +// logout(). Keep a stable reference so tests can assert calls — returning a +// fresh object per useProfileStore() invocation would scatter spies across +// throwaway objects. +const profileStore = { + bootstrap: vi.fn(), + logout: vi.fn(), +}; vi.mock("../src/stores/profile", () => ({ - useProfileStore: () => ({ - logout: vi.fn(), - bootstrap: vi.fn().mockResolvedValue(undefined), - }), + useProfileStore: () => profileStore, })); // Mixpanel is wired everywhere via the analytic singleton; in tests we don't // want network or to require dotenv-injected env vars. +const analyticMock = { + identify: vi.fn(), + track: vi.fn(), + register: vi.fn(), + reset: vi.fn(), + people: { set: vi.fn() }, +}; vi.mock("../src/plugins/mixpanel", () => ({ - analytic: { - identify: vi.fn(), - track: vi.fn(), - register: vi.fn(), - reset: vi.fn(), - people: { set: vi.fn() }, - }, + analytic: analyticMock, })); -// Capture chrome.runtime.sendMessage so we can assert what crosses to the -// background. The setup.ts shim makes it a vi.fn() that resolves with {}. function getSendMessageMock() { return (globalThis as any).chrome.runtime.sendMessage as ReturnType< typeof vi.fn >; } +// chrome.runtime.onMessage.addListener is registered at module import; grab +// the most recently registered handler (after vi.resetModules + re-import). +function getRuntimeOnMessageListener() { + const addListener = (globalThis as any).chrome.runtime.onMessage + .addListener as ReturnType; + const lastCall = addListener.mock.calls.at(-1); + if (!lastCall) throw new Error("modular-rest did not register onMessage"); + return lastCall[0] as (request: any, sender?: any) => void; +} + // Make chrome.runtime.sendMessage shape its response based on which message // type was passed. GetLoginStatusMessage callers expect {status, token}, // everyone else can get the default {} from the setup shim. @@ -83,8 +93,14 @@ function stubBackgroundLoginStatus(token: string | null) { ); } -describe("loginWithLastSession (anonymous flow)", () => { - let loginWithLastSession: typeof import("../src/plugins/modular-rest").loginWithLastSession; +function storeTokenCalls() { + return getSendMessageMock().mock.calls.filter( + ([m]) => m && (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN + ); +} + +describe("loginWithLastSession", () => { + let mod: typeof import("../src/plugins/modular-rest"); beforeEach(async () => { setActivePinia(createPinia()); @@ -102,141 +118,272 @@ describe("loginWithLastSession (anonymous flow)", () => { auth.getToken = null; }); - // Reset the chrome shim default. + profileStore.bootstrap.mockReset(); + profileStore.bootstrap.mockResolvedValue(undefined); + profileStore.logout.mockReset(); + + analyticMock.identify.mockReset(); + analyticMock.reset.mockReset(); + analyticMock.people.set.mockReset(); + getSendMessageMock().mockReset(); stubBackgroundLoginStatus(null); - - // Reset localStorage between tests (happy-dom gives us a real one). - localStorage.clear(); + (globalThis as any).chrome.tabs.sendMessage.mockReset?.(); // Re-import the plugin fresh each test so the chrome.runtime.onMessage // listener doesn't accumulate. vi.resetModules(); - const mod = await import("../src/plugins/modular-rest"); - loginWithLastSession = mod.loginWithLastSession; + mod = await import("../src/plugins/modular-rest"); - // Suppress noisy console output from the plugin's anon-login console.log - // and bootstrap error path. vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); }); - it("falls through to anonymous login when no token is stored", async () => { - auth.loginAsAnonymous.mockImplementation(async () => { - auth.isLogin = true; - auth.user = { id: "anon-1", type: "anonymous" }; - auth.getToken = "anon-token-abc"; - return { token: "anon-token-abc" }; + describe("anonymous fallback", () => { + it("falls through to anonymous login when no token is stored", async () => { + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.user = { id: "anon-1", type: "anonymous" }; + auth.getToken = "anon-token-abc"; + return { token: "anon-token-abc" }; + }); + + await mod.loginWithLastSession(); + await new Promise((r) => setTimeout(r, 0)); + + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); }); - await loginWithLastSession(); - // .finally fires the anon login asynchronously; let microtasks settle. - await new Promise((r) => setTimeout(r, 0)); + it("persists the new anonymous token to chrome.storage.sync via background", async () => { + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.user = { id: "anon-1", type: "anonymous" }; + auth.getToken = "anon-token-abc"; + return { token: "anon-token-abc" }; + }); - expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); - }); + await mod.loginWithLastSession(); + await new Promise((r) => setTimeout(r, 0)); - it("persists the new anonymous token to chrome.storage.sync and localStorage", async () => { - auth.loginAsAnonymous.mockImplementation(async () => { - auth.isLogin = true; - auth.user = { id: "anon-1", type: "anonymous" }; - auth.getToken = "anon-token-abc"; - return { token: "anon-token-abc" }; + // The "no token" path emits two StoreUserTokenMessages: the wrapper + // logout() that runs because authentication.isLogin was still false + // sends StoreUserTokenMessage(null) first, then the anon-fallback .then + // in the finally writes the fresh anon token. Only the LAST write + // matters — the background's chrome.storage.sync ends up populated. + // (The localStorage write at modular-rest.ts:173-175 is commented out + // because of the dashboard-origin clobber bug — so we don't assert on + // localStorage here.) + const calls = storeTokenCalls(); + expect(calls.length).toBeGreaterThanOrEqual(1); + const last = calls.at(-1)![0] as StoreUserTokenMessage; + expect(last.token).toBe("anon-token-abc"); }); - await loginWithLastSession(); - await new Promise((r) => setTimeout(r, 0)); + it("logs an error and does not crash when loginAsAnonymous itself rejects", async () => { + auth.loginAsAnonymous.mockRejectedValue(new Error("network down")); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(mod.loginWithLastSession()).resolves.toBeUndefined(); + await new Promise((r) => setTimeout(r, 0)); - // The "no token" path actually emits two StoreUserTokenMessages: the - // wrapper logout() that runs because authentication.isLogin was still - // false sends StoreUserTokenMessage(null) first, then the anon-fallback - // .then in the finally writes the fresh anon token. The end state is - // what matters — the LAST write must be the new anon token, so the - // background's chrome.storage.sync ends up populated. - const sendMessage = getSendMessageMock(); - const storeCalls = sendMessage.mock.calls.filter( - ([m]) => - m && (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN - ); - expect(storeCalls.length).toBeGreaterThanOrEqual(1); - const lastStore = storeCalls[storeCalls.length - 1][0] as StoreUserTokenMessage; - expect(lastStore.token).toBe("anon-token-abc"); - - // localStorage cache for the page itself, mirroring what - // @modular-rest/client's authentication.saveSession() would do. - expect(localStorage.getItem("token")).toBe("anon-token-abc"); + expect(errSpy).toHaveBeenCalledWith( + "Subturtle anonymous login failed", + expect.any(Error) + ); + expect(storeTokenCalls().some(([m]) => (m as any).token !== null)).toBe( + false + ); + }); }); - it("does NOT broadcast logout when the token validates as an anonymous user", async () => { - // Background returns a stored anon token (the success path the user hits - // every fresh popup open). - stubBackgroundLoginStatus("anon-token-abc"); - auth.loginWithToken.mockImplementation(async (token: string) => { - auth.isLogin = true; - auth.user = { id: "anon-1", type: "anonymous" }; - auth.getToken = token; - return auth.user; + describe("stored anonymous token", () => { + // FIXME: this pins the current behavior introduced in commit 6aff7ed + // (`tweak on login steps from initial moment`) which added an + // `if (user.type == "anonymous") authentication.logout()` block at + // src/plugins/modular-rest.ts:122-124. That contradicts the comment block + // at src/plugins/modular-rest.ts:130-139 (kept from the prior + // logout-cascade fix in 825db93) which still claims anon users hold a + // valid session at the outer .then. If you decide the comment is the + // intended behavior, drop the inner logout — and update this test to + // assert no re-roll, no null broadcast, isLogin remains true. + it("re-rolls the anonymous session when a stored anon token is returned", async () => { + stubBackgroundLoginStatus("stale-anon"); + auth.loginWithToken.mockImplementation(async (token: string) => { + auth.isLogin = true; + auth.user = { id: "anon-1", type: "anonymous" }; + auth.getToken = token; + return auth.user; + }); + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.user = { id: "anon-2", type: "anonymous" }; + auth.getToken = "fresh-anon"; + return { token: "fresh-anon" }; + }); + + await mod.loginWithLastSession(); + await new Promise((r) => setTimeout(r, 0)); + + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + const last = storeTokenCalls().at(-1)![0] as StoreUserTokenMessage; + expect(last.token).toBe("fresh-anon"); }); - await loginWithLastSession(); - await new Promise((r) => setTimeout(r, 0)); + it("falls through to a fresh anon login when a stored token is rejected by the server", async () => { + stubBackgroundLoginStatus("stale-token"); + auth.loginWithToken.mockImplementation(async () => { + // modular-rest's internal loginWithToken catch path calls + // authentication.logout() before rethrowing. Mirror that. + auth.logout(); + throw new Error("token rejected"); + }); + auth.loginAsAnonymous.mockImplementation(async () => { + auth.isLogin = true; + auth.user = { id: "anon-2", type: "anonymous" }; + auth.getToken = "fresh-anon"; + return { token: "fresh-anon" }; + }); - // The wrapper logout() would broadcast StoreUserTokenMessage(null) and - // call authentication.logout(). Neither must happen for an anon session - // — that's the cascade that wiped chrome.storage.sync and 412'd every - // subsequent translate before the fix. - expect(auth.logout).not.toHaveBeenCalled(); - const sendMessage = getSendMessageMock(); - const nullStoreCalls = sendMessage.mock.calls.filter( - ([m]) => - m && - (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN && - (m as any).token === null - ); - expect(nullStoreCalls).toHaveLength(0); - - // And we should NOT have re-rolled an anon login when validation worked. - expect(auth.loginAsAnonymous).not.toHaveBeenCalled(); + // The plugin's promise chain doesn't catch loginWithToken rejections, + // so the rejection propagates out. The .finally anon-fallback still + // runs first. + await mod.loginWithLastSession().catch(() => undefined); + await new Promise((r) => setTimeout(r, 0)); + + expect(auth.logout).toHaveBeenCalled(); + expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); + const last = storeTokenCalls().at(-1)?.[0] as + | StoreUserTokenMessage + | undefined; + expect(last?.token).toBe("fresh-anon"); + }); }); - it("falls through to a fresh anon login when a stored token is rejected by the server", async () => { - stubBackgroundLoginStatus("stale-token"); - auth.loginWithToken.mockImplementation(async () => { - // modular-rest's internal loginWithToken catch path calls - // authentication.logout() before rethrowing. Mirror that. - auth.logout(); - throw new Error("token rejected"); + describe("registered user", () => { + it("keeps the session and bootstraps the profile store on a valid registered token", async () => { + stubBackgroundLoginStatus("user-token"); + auth.loginWithToken.mockImplementation(async (token: string) => { + auth.isLogin = true; + auth.user = { id: "user-1", type: "user", email: "x@y.z" }; + auth.getToken = token; + return auth.user; + }); + + await mod.loginWithLastSession(); + await new Promise((r) => setTimeout(r, 0)); + + expect(profileStore.bootstrap).toHaveBeenCalledTimes(1); + expect(analyticMock.identify).toHaveBeenCalledWith("user-1"); + expect(analyticMock.people.set).toHaveBeenCalledWith({ $email: "x@y.z" }); + expect(mod.isLogin.value).toBe(true); + + // No anon re-roll, no clear-broadcast. + expect(auth.loginAsAnonymous).not.toHaveBeenCalled(); + expect( + storeTokenCalls().some(([m]) => (m as any).token === null) + ).toBe(false); + }); + + // This is the regression the comment block at modular-rest.ts:130-139 + // describes: when updateIsLogin returns falsy (here: bootstrap rejected + // and the .catch returns false) but authentication.isLogin is still + // true, we MUST NOT broadcast logout — that would clear chrome.storage + // .sync and 412 the next translate. + it("does not broadcast logout when bootstrap rejects but the token is valid", async () => { + stubBackgroundLoginStatus("user-token"); + auth.loginWithToken.mockImplementation(async (token: string) => { + auth.isLogin = true; + auth.user = { id: "user-1", type: "user", email: "x@y.z" }; + auth.getToken = token; + return auth.user; + }); + profileStore.bootstrap.mockRejectedValue(new Error("network down")); + + await mod.loginWithLastSession(); + await new Promise((r) => setTimeout(r, 0)); + + expect(auth.isLogin).toBe(true); + expect(auth.loginAsAnonymous).not.toHaveBeenCalled(); + expect( + storeTokenCalls().some(([m]) => (m as any).token === null) + ).toBe(false); }); - auth.loginAsAnonymous.mockImplementation(async () => { - auth.isLogin = true; - auth.user = { id: "anon-2", type: "anonymous" }; - auth.getToken = "fresh-anon"; - return { token: "fresh-anon" }; + }); +}); + +describe("chrome.runtime.onMessage StoreUserTokenMessage listener", () => { + beforeEach(async () => { + setActivePinia(createPinia()); + auth.isLogin = false; + auth.user = null; + auth.getToken = null; + auth.loginWithToken.mockReset(); + auth.loginAsAnonymous.mockReset(); + auth.logout.mockReset(); + auth.logout.mockImplementation(() => { + auth.isLogin = false; + auth.user = null; + auth.getToken = null; }); - // The plugin's promise chain doesn't catch loginWithToken rejections, so - // the rejection propagates out of loginWithLastSession. The .finally - // anon-fallback still runs first. Swallow here so the test asserts on - // observable side-effects rather than the throw itself. - await loginWithLastSession().catch(() => undefined); + profileStore.bootstrap.mockReset(); + profileStore.bootstrap.mockResolvedValue(undefined); + profileStore.logout.mockReset(); + analyticMock.reset.mockReset(); + + getSendMessageMock().mockReset(); + stubBackgroundLoginStatus(null); + (globalThis as any).chrome.tabs.sendMessage.mockReset?.(); + + vi.resetModules(); + await import("../src/plugins/modular-rest"); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("token=null logs the user out without re-broadcasting", async () => { + auth.isLogin = true; + auth.user = { id: "user-1", type: "user" }; + + const listener = getRuntimeOnMessageListener(); + listener(new StoreUserTokenMessage(null)); await new Promise((r) => setTimeout(r, 0)); - // modular-rest's internal logout fired (mocked above before throwing). expect(auth.logout).toHaveBeenCalled(); + expect(profileStore.logout).toHaveBeenCalled(); + expect(analyticMock.reset).toHaveBeenCalled(); + + // logout(false) — no broadcast back out (would loop otherwise). + expect( + getSendMessageMock().mock.calls.some( + ([m]) => (m as any)?.type === MESSAGE_TYPE.STORE_USER_TOKEN + ) + ).toBe(false); + expect( + (globalThis as any).chrome.tabs.sendMessage.mock.calls.length + ).toBe(0); + }); + + it("token= re-enters loginWithLastSession", async () => { + // Re-entering should hit the GetLoginStatusMessage path. We just verify + // the GetLoginStatus round-trip happens — full recursion behavior is + // already covered by the loginWithLastSession suite above. Stub the + // anon-fallback so the .finally branch doesn't dereference an unstubbed + // vi.fn() return. + auth.loginAsAnonymous.mockResolvedValue({ token: "anon" }); + + const listener = getRuntimeOnMessageListener(); + listener(new StoreUserTokenMessage("incoming-token")); + await new Promise((r) => setTimeout(r, 0)); - // And we fell through to a fresh anon login that overwrites the stale - // token in chrome.storage.sync with the new one — recovery without the - // user having to do anything. - expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1); - - const sendMessage = getSendMessageMock(); - const storeCalls = sendMessage.mock.calls.filter( - ([m]) => - m && (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN - ); - const lastStore = storeCalls[storeCalls.length - 1]?.[0] as - | StoreUserTokenMessage - | undefined; - expect(lastStore?.token).toBe("fresh-anon"); + expect( + getSendMessageMock().mock.calls.some( + ([m]) => (m as any)?.type === MESSAGE_TYPE.GET_LOGIN_STATUS + ) + ).toBe(true); }); }); diff --git a/tests/e2e/auth-survives-language-switch.spec.ts b/tests/e2e/auth-survives-language-switch.spec.ts index 92b266b..fd651f2 100644 --- a/tests/e2e/auth-survives-language-switch.spec.ts +++ b/tests/e2e/auth-survives-language-switch.spec.ts @@ -17,8 +17,9 @@ import type { Route, Page } from "@playwright/test"; // saw "Translation failed." // // This test pins both halves of the fix: -// * After an anonymous login, chrome.storage.sync is populated and the -// in-page localStorage cache holds the same token. +// * After an anonymous login, chrome.storage.sync is populated with the +// anon token (the in-page localStorage write at modular-rest.ts:173-175 +// was later commented out, so we don't assert on it here). // * Mutating chrome.storage.local.settings to simulate a popup language // change does NOT clear chrome.storage.sync.token, and a fresh // /function/run still goes out with a non-empty Authorization header. @@ -116,11 +117,6 @@ test.describe("auth survives language switch (anonymous user)", () => { ) .toBe(ANON_TOKEN); - // Same token in the page's localStorage (modular-rest's per-origin cache). - expect( - await page.evaluate(() => localStorage.getItem("token")) - ).toBe(ANON_TOKEN); - // First translate — works, Authorization header carries the anon token. await page.locator("#test-word").click({ clickCount: 2 }); await expect(page.locator(".nibble-icon-btn")).toBeVisible({