|
| 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