0eaaa54e25
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
87 lines
4.2 KiB
JavaScript
87 lines
4.2 KiB
JavaScript
/**
|
|
* 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.<userId>.<projectId>), 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");
|
|
});
|