Skip to content

Commit 1a85bcb

Browse files
authored
Merge pull request #20 from codebridger/dev
fix: persist anonymous tokens and stop logout-cascade on anon sessions
2 parents 7847f0e + 825db93 commit 1a85bcb

4 files changed

Lines changed: 470 additions & 11 deletions

File tree

src/nibble/composables/useTextSelection.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,15 @@ export function useTextSelection() {
152152
}
153153

154154
function onDocClick(e: MouseEvent) {
155-
const target = e.target as Element | null;
156-
if (!target) return;
157-
if (target.closest(`#${NIBBLE_ROOT_ID}`)) return;
155+
const target = e.target;
156+
const el =
157+
target instanceof Element
158+
? target
159+
: target instanceof Node
160+
? target.parentElement
161+
: null;
162+
if (!el) return;
163+
if (el.closest(`#${NIBBLE_ROOT_ID}`)) return;
158164
if (window.getSelection()?.isCollapsed !== false) return;
159165
}
160166

src/plugins/modular-rest.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,27 +76,52 @@ export async function loginWithLastSession() {
7676
return user;
7777
})
7878
.then((_user) => updateIsLogin())
79-
.then(async (isSuccess) => {
80-
81-
// if the login failed, it means token is invalid or expired.
82-
// so the token should be removed from the storage.
83-
if (!isSuccess) {
79+
.then(async (_isRegisteredUser) => {
80+
// updateIsLogin's truthy result means "registered user with a real
81+
// account". Anonymous users return false here even though they hold a
82+
// perfectly valid session — so don't conflate "not a registered user"
83+
// with "login failed". Only broadcast logout when the underlying token
84+
// truly couldn't be validated (authentication.isLogin === false).
85+
// Without this guard, every fresh popup open would `logout()` the anon
86+
// session, clearing chrome.storage.sync and broadcasting null to every
87+
// tab — and the next translate from any content script then 412s
88+
// because its Authorization header is empty.
89+
if (!authentication.isLogin) {
8490
await logout();
8591
return false;
8692
}
87-
88-
return isSuccess;
93+
return true;
8994
})
9095

9196
.finally(() => {
9297
if (!authentication.isLogin) {
9398
authentication
9499
.loginAsAnonymous()
95-
.then((user) => {
100+
.then(async () => {
96101
console.log(
97102
"Subturtle Anonymous login succeded",
98103
authentication.isLogin
99104
);
105+
// Persist the anonymous token so subsequent mounts (other bundles
106+
// on the same page, the popup, page reloads) reuse it instead of
107+
// each calling /user/loginAnonymous and stranding the previous
108+
// anonymous user — which the server then 412s on the next call.
109+
// Writes to chrome.storage.sync (cross-context) and to this
110+
// page's localStorage (modular-rest's own per-origin cache).
111+
const token = authentication.getToken;
112+
if (token) {
113+
try {
114+
await sendMessage(new StoreUserTokenMessage(token));
115+
} catch (err) {
116+
console.warn(
117+
"Subturtle: persisting anonymous token to background failed",
118+
err
119+
);
120+
}
121+
if (typeof localStorage !== "undefined") {
122+
localStorage.setItem("token", token);
123+
}
124+
}
100125
updateIsLogin();
101126
})
102127
.catch((err) => {

tests/auth-anon-flow.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { setActivePinia, createPinia } from "pinia";
3+
import {
4+
MESSAGE_TYPE,
5+
StoreUserTokenMessage,
6+
type LoginStatusResponse,
7+
} from "../src/common/types/messaging";
8+
9+
// Controllable @modular-rest/client mock. The plugin module under test reads
10+
// `authentication.{isLogin,user,getToken}` and calls
11+
// `loginWithToken|loginAsAnonymous|logout`. We expose hooks so each test can
12+
// shape the auth state before exercising loginWithLastSession.
13+
const auth = {
14+
isLogin: false,
15+
user: null as null | { id: string; type: string; email?: string },
16+
getToken: null as string | null,
17+
loginWithToken: vi.fn(),
18+
loginAsAnonymous: vi.fn(),
19+
logout: vi.fn(() => {
20+
auth.isLogin = false;
21+
auth.user = null;
22+
auth.getToken = null;
23+
}),
24+
};
25+
26+
vi.mock("@modular-rest/client", () => ({
27+
GlobalOptions: { set: vi.fn() },
28+
authentication: auth,
29+
dataProvider: {},
30+
fileProvider: {},
31+
functionProvider: { run: vi.fn() },
32+
}));
33+
34+
// useProfileStore is only invoked inside updateIsLogin's registered-user
35+
// branch and inside logout(); the anon flow doesn't hit those, but logout() is
36+
// still called when the token truly fails to validate. Keep it as a no-op so
37+
// it doesn't pull in the sibling dashboard-app type imports at module load.
38+
vi.mock("../src/stores/profile", () => ({
39+
useProfileStore: () => ({
40+
logout: vi.fn(),
41+
bootstrap: vi.fn().mockResolvedValue(undefined),
42+
}),
43+
}));
44+
45+
// Mixpanel is wired everywhere via the analytic singleton; in tests we don't
46+
// want network or to require dotenv-injected env vars.
47+
vi.mock("../src/plugins/mixpanel", () => ({
48+
analytic: {
49+
identify: vi.fn(),
50+
track: vi.fn(),
51+
register: vi.fn(),
52+
reset: vi.fn(),
53+
people: { set: vi.fn() },
54+
},
55+
}));
56+
57+
// Capture chrome.runtime.sendMessage so we can assert what crosses to the
58+
// background. The setup.ts shim makes it a vi.fn() that resolves with {}.
59+
function getSendMessageMock() {
60+
return (globalThis as any).chrome.runtime.sendMessage as ReturnType<
61+
typeof vi.fn
62+
>;
63+
}
64+
65+
// Make chrome.runtime.sendMessage shape its response based on which message
66+
// type was passed. GetLoginStatusMessage callers expect {status, token},
67+
// everyone else can get the default {} from the setup shim.
68+
function stubBackgroundLoginStatus(token: string | null) {
69+
const sendMessage = getSendMessageMock();
70+
sendMessage.mockImplementation(
71+
(message: any, callback?: (response: any) => void) => {
72+
if (message?.type === MESSAGE_TYPE.GET_LOGIN_STATUS) {
73+
const response: LoginStatusResponse = {
74+
status: !!token,
75+
...(token ? { token } : {}),
76+
};
77+
callback?.(response);
78+
return Promise.resolve(response);
79+
}
80+
callback?.({});
81+
return Promise.resolve({});
82+
}
83+
);
84+
}
85+
86+
describe("loginWithLastSession (anonymous flow)", () => {
87+
let loginWithLastSession: typeof import("../src/plugins/modular-rest").loginWithLastSession;
88+
89+
beforeEach(async () => {
90+
setActivePinia(createPinia());
91+
92+
// Reset auth state.
93+
auth.isLogin = false;
94+
auth.user = null;
95+
auth.getToken = null;
96+
auth.loginWithToken.mockReset();
97+
auth.loginAsAnonymous.mockReset();
98+
auth.logout.mockReset();
99+
auth.logout.mockImplementation(() => {
100+
auth.isLogin = false;
101+
auth.user = null;
102+
auth.getToken = null;
103+
});
104+
105+
// Reset the chrome shim default.
106+
getSendMessageMock().mockReset();
107+
stubBackgroundLoginStatus(null);
108+
109+
// Reset localStorage between tests (happy-dom gives us a real one).
110+
localStorage.clear();
111+
112+
// Re-import the plugin fresh each test so the chrome.runtime.onMessage
113+
// listener doesn't accumulate.
114+
vi.resetModules();
115+
const mod = await import("../src/plugins/modular-rest");
116+
loginWithLastSession = mod.loginWithLastSession;
117+
118+
// Suppress noisy console output from the plugin's anon-login console.log
119+
// and bootstrap error path.
120+
vi.spyOn(console, "log").mockImplementation(() => {});
121+
vi.spyOn(console, "warn").mockImplementation(() => {});
122+
});
123+
124+
it("falls through to anonymous login when no token is stored", async () => {
125+
auth.loginAsAnonymous.mockImplementation(async () => {
126+
auth.isLogin = true;
127+
auth.user = { id: "anon-1", type: "anonymous" };
128+
auth.getToken = "anon-token-abc";
129+
return { token: "anon-token-abc" };
130+
});
131+
132+
await loginWithLastSession();
133+
// .finally fires the anon login asynchronously; let microtasks settle.
134+
await new Promise((r) => setTimeout(r, 0));
135+
136+
expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1);
137+
});
138+
139+
it("persists the new anonymous token to chrome.storage.sync and localStorage", async () => {
140+
auth.loginAsAnonymous.mockImplementation(async () => {
141+
auth.isLogin = true;
142+
auth.user = { id: "anon-1", type: "anonymous" };
143+
auth.getToken = "anon-token-abc";
144+
return { token: "anon-token-abc" };
145+
});
146+
147+
await loginWithLastSession();
148+
await new Promise((r) => setTimeout(r, 0));
149+
150+
// The "no token" path actually emits two StoreUserTokenMessages: the
151+
// wrapper logout() that runs because authentication.isLogin was still
152+
// false sends StoreUserTokenMessage(null) first, then the anon-fallback
153+
// .then in the finally writes the fresh anon token. The end state is
154+
// what matters — the LAST write must be the new anon token, so the
155+
// background's chrome.storage.sync ends up populated.
156+
const sendMessage = getSendMessageMock();
157+
const storeCalls = sendMessage.mock.calls.filter(
158+
([m]) =>
159+
m && (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN
160+
);
161+
expect(storeCalls.length).toBeGreaterThanOrEqual(1);
162+
const lastStore = storeCalls[storeCalls.length - 1][0] as StoreUserTokenMessage;
163+
expect(lastStore.token).toBe("anon-token-abc");
164+
165+
// localStorage cache for the page itself, mirroring what
166+
// @modular-rest/client's authentication.saveSession() would do.
167+
expect(localStorage.getItem("token")).toBe("anon-token-abc");
168+
});
169+
170+
it("does NOT broadcast logout when the token validates as an anonymous user", async () => {
171+
// Background returns a stored anon token (the success path the user hits
172+
// every fresh popup open).
173+
stubBackgroundLoginStatus("anon-token-abc");
174+
auth.loginWithToken.mockImplementation(async (token: string) => {
175+
auth.isLogin = true;
176+
auth.user = { id: "anon-1", type: "anonymous" };
177+
auth.getToken = token;
178+
return auth.user;
179+
});
180+
181+
await loginWithLastSession();
182+
await new Promise((r) => setTimeout(r, 0));
183+
184+
// The wrapper logout() would broadcast StoreUserTokenMessage(null) and
185+
// call authentication.logout(). Neither must happen for an anon session
186+
// — that's the cascade that wiped chrome.storage.sync and 412'd every
187+
// subsequent translate before the fix.
188+
expect(auth.logout).not.toHaveBeenCalled();
189+
const sendMessage = getSendMessageMock();
190+
const nullStoreCalls = sendMessage.mock.calls.filter(
191+
([m]) =>
192+
m &&
193+
(m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN &&
194+
(m as any).token === null
195+
);
196+
expect(nullStoreCalls).toHaveLength(0);
197+
198+
// And we should NOT have re-rolled an anon login when validation worked.
199+
expect(auth.loginAsAnonymous).not.toHaveBeenCalled();
200+
});
201+
202+
it("falls through to a fresh anon login when a stored token is rejected by the server", async () => {
203+
stubBackgroundLoginStatus("stale-token");
204+
auth.loginWithToken.mockImplementation(async () => {
205+
// modular-rest's internal loginWithToken catch path calls
206+
// authentication.logout() before rethrowing. Mirror that.
207+
auth.logout();
208+
throw new Error("token rejected");
209+
});
210+
auth.loginAsAnonymous.mockImplementation(async () => {
211+
auth.isLogin = true;
212+
auth.user = { id: "anon-2", type: "anonymous" };
213+
auth.getToken = "fresh-anon";
214+
return { token: "fresh-anon" };
215+
});
216+
217+
// The plugin's promise chain doesn't catch loginWithToken rejections, so
218+
// the rejection propagates out of loginWithLastSession. The .finally
219+
// anon-fallback still runs first. Swallow here so the test asserts on
220+
// observable side-effects rather than the throw itself.
221+
await loginWithLastSession().catch(() => undefined);
222+
await new Promise((r) => setTimeout(r, 0));
223+
224+
// modular-rest's internal logout fired (mocked above before throwing).
225+
expect(auth.logout).toHaveBeenCalled();
226+
227+
// And we fell through to a fresh anon login that overwrites the stale
228+
// token in chrome.storage.sync with the new one — recovery without the
229+
// user having to do anything.
230+
expect(auth.loginAsAnonymous).toHaveBeenCalledTimes(1);
231+
232+
const sendMessage = getSendMessageMock();
233+
const storeCalls = sendMessage.mock.calls.filter(
234+
([m]) =>
235+
m && (m as any).type === MESSAGE_TYPE.STORE_USER_TOKEN
236+
);
237+
const lastStore = storeCalls[storeCalls.length - 1]?.[0] as
238+
| StoreUserTokenMessage
239+
| undefined;
240+
expect(lastStore?.token).toBe("fresh-anon");
241+
});
242+
});

0 commit comments

Comments
 (0)