/** * Regression (#68): switching accounts in the same browser must keep draft caches isolated per user. * * Old bug: the draft key only included the Project/Session ID, with no user dimension. If user A * left an unsent draft in a shared Project and logged out, user B logging in and visiting the * same Project would restore A's body and selections — an info leak across accounts. After the * fix, the key includes userId (penguin.chatDraft..), so each account only sees its own draft. */ import { test, expect } from "@playwright/test"; import { provisionAndLogin, provisionUser } from "./auth.mjs"; const BASE = process.env.BASE_URL; const MOCK = process.env.MOCK_URL; const P = "password123"; const UA = "draftowner"; const UB = "draftguest"; test("switching accounts: B does not restore A's draft; both drafts coexist", async ({ page }) => { // A is provisioned and logged in (page's cookie belongs to A); B is only provisioned, logging in via the UI later. userId is the username. const userA = (await provisionAndLogin(page.request, UA, P)).userId; await provisionUser(UB, P); const userB = UB; // Configure a model on A's initial Project (missing credentials would trigger the onboarding // prompt), then add B as a member (shared Project). After logging in, B lands on projects[0] — // the list is sorted by creation time ascending, and A's Project is older, exactly reproducing the issue's scenario. const projectId = (await (await page.request.get(`${BASE}/api/projects`)).json()).projects[0] .projectId; const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, { data: { defaultModel: { provider: "custom", modelId: "claude-4-8" }, models: [ { provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock", baseUrl: MOCK, contextWindow: 200000, }, ], }, }); expect(put.ok(), "put models").toBeTruthy(); const add = await page.request.post(`${BASE}/api/projects/${projectId}/members`, { data: { userId: UB }, }); expect(add.ok(), "add member").toBeTruthy(); // A types a body on the draft page and waits for debounce to persist it under A's key; after a reload it restores as usual (confirming it was persisted). await page.goto(`${BASE}/chat`); const ta = page.getByPlaceholder(/输入消息/); await ta.fill("A's secret draft"); const keyA = `penguin.chatDraft.${userA}.${projectId}`; await expect .poll(() => page.evaluate((k) => localStorage.getItem(k), keyA)) .toContain("A's secret draft"); await page.reload(); await expect(ta).toHaveValue("A's secret draft"); // A logs out: the bottom-left user menu (the username button shares its name with the top // Project switcher — the initial Project's display name defaults to the username — so take // the last match, which is the bottom user menu). await page.getByRole("button", { name: UA }).last().click(); await page.getByRole("button", { name: "登出" }).click(); await page.waitForURL(/\/login/); // B logs in on the same browser, landing on the draft page for the shared Project. await page.getByLabel("用户名").fill(UB); // exact: without it, a substring match would also hit the "show password" toggle button (aria-label) next to the password field. await page.getByLabel("密码", { exact: true }).fill(P); await page.locator('button[type="submit"]').click(); await page.waitForURL(/\/chat/); await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible(); // The leak's regression point: B's input area must be empty (before the fix, it would restore A's body and selections). await expect(ta).toHaveValue(""); // B writes their own draft: it lands under B's key (the key includes projectId, confirming B // is on the same shared Project), while A's draft is left untouched — neither overwrites the other. await ta.fill("B's own draft"); const keyB = `penguin.chatDraft.${userB}.${projectId}`; await expect .poll(() => page.evaluate((k) => localStorage.getItem(k), keyB)) .toContain("B's own draft"); expect(await page.evaluate((k) => localStorage.getItem(k), keyA)).toContain("A's secret draft"); });