mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
This commit is contained in:
60 files changed
+5648
-174
No files matched your search
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Account sharing, end to end: invite → accept → switch → banner → revoke.
|
||||
*
|
||||
* Two browser contexts, two real accounts, one real database. The owner
|
||||
* invites, the delegate accepts and opens the record, and the owner takes it
|
||||
* back — each step through the shipped UI rather than through the API, because
|
||||
* the half of this feature that can go wrong is the half a person touches.
|
||||
*
|
||||
* ## The stale-paint check
|
||||
*
|
||||
* The assertion this spec exists for is a NEGATIVE one, and it is deliberately
|
||||
* not "the new record's data arrives". A check that waits for the new data
|
||||
* proves nothing about what was on screen while it was arriving, and the
|
||||
* failure being guarded against is exactly that in-between frame: a dashboard
|
||||
* hydrated from the previous record's IndexedDB snapshot, painted under a
|
||||
* banner naming somebody else.
|
||||
*
|
||||
* So an init script installs a MutationObserver before any page script runs,
|
||||
* on every navigation including the full reload the switch performs, and it
|
||||
* latches the moment the delegate's own marker weight appears in the document.
|
||||
* The spec then reads the latch. Nothing about it is sampled or timed.
|
||||
*
|
||||
* The observer's positive control runs first: with the same script installed,
|
||||
* the delegate's own measurements page must trip the latch. Without that, "the
|
||||
* marker never appeared" and "the observer never worked" are the same sentence.
|
||||
*
|
||||
* ### How much this assertion can be made to fail, measured rather than assumed
|
||||
*
|
||||
* Three controls stand behind it, and they were removed one at a time and
|
||||
* together against a real build to see which of them this spec can actually
|
||||
* detect. The honest answer is: none of them individually, and that is worth
|
||||
* writing down rather than leaving as an implication.
|
||||
*
|
||||
* - Delete the switch-time cache wipe, the persister's restore guard and the
|
||||
* record-scoped query hash — all three at once — and this spec stays
|
||||
* GREEN. The reason is a rule that predates this feature: the restore
|
||||
* refuses to clobber a query the live cache already holds, and the
|
||||
* dashboard's own query is created during render, before any effect runs.
|
||||
* So the IndexedDB snapshot never reaches the screen on this route at all.
|
||||
* - Delete the full reload and the spec goes RED, but on the banner line
|
||||
* rather than the marker line: without a reload the account payload never
|
||||
* re-resolves and the banner never paints, so the run stops before the
|
||||
* latch is read.
|
||||
*
|
||||
* What that means, plainly: this spec is a regression net over the whole
|
||||
* journey, not a proof of any single control. The controls are proven where
|
||||
* they can be broken and observed — `query-persister-shared-record.test.ts`
|
||||
* for the two disk guards, `record-scope.test.ts` for the cache dimension —
|
||||
* and each of those was verified red before being kept.
|
||||
*
|
||||
* ## What this spec cannot cover, stated rather than implied
|
||||
*
|
||||
* - **The service worker.** The Playwright context runs with
|
||||
* `serviceWorkers: "block"` (see `playwright.config.ts`), so the worker's
|
||||
* `healthlog-data-*` cache — the other layer that survives a reload — is not
|
||||
* exercised here at all. The switch wipes it through the same helper the
|
||||
* IndexedDB snapshot goes through, and that helper is covered in
|
||||
* `src/lib/pwa/__tests__/query-persister-shared-record.test.ts`.
|
||||
* - **The owner's data appearing.** No route declares the delegable mode yet,
|
||||
* so every read inside a shared record is refused by design. That is the
|
||||
* current, correct state of the tree; the pages a delegate can open show
|
||||
* their refusal state and, crucially, still show nothing of the delegate's
|
||||
* own. The positive half of that assertion belongs with the route migration.
|
||||
*/
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER,
|
||||
E2E_USER,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
|
||||
const DELEGATE_MARKER = String(E2E_DELEGATE_MARKER_KG);
|
||||
|
||||
/**
|
||||
* Latch the first appearance of a string anywhere in the document, from before
|
||||
* the first script of every page load.
|
||||
*
|
||||
* A MutationObserver rather than a poll: a poll samples, and the frame this is
|
||||
* looking for can be one paint long. `characterData` is on because a hydration
|
||||
* pass can fill an existing text node in place rather than inserting one.
|
||||
*/
|
||||
async function watchForText(page: Page, needle: string): Promise<void> {
|
||||
await page.addInitScript((text: string) => {
|
||||
const w = window as unknown as { __sawText?: boolean };
|
||||
w.__sawText = false;
|
||||
const seen = () => document.body?.innerText?.includes(text) ?? false;
|
||||
const check = () => {
|
||||
if (seen()) w.__sawText = true;
|
||||
};
|
||||
const start = () => {
|
||||
check();
|
||||
new MutationObserver(check).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}, needle);
|
||||
}
|
||||
|
||||
async function sawText(page: Page): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __sawText?: boolean }).__sawText === true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the user menu and the switcher inside it. */
|
||||
async function openSwitcher(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-menu"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("account sharing", () => {
|
||||
// One journey, in order: each step is the precondition of the next, so
|
||||
// splitting them into independent tests would mean re-running the invitation
|
||||
// three times against a rate-limited endpoint.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// The `page` fixture throughout is the DELEGATE. The owner gets an explicit
|
||||
// context below, because a grant needs two signed-in sides at once and the
|
||||
// login endpoint is IP-rate-limited.
|
||||
//
|
||||
// The delegate's jar is this journey's OWN, not the shared one every other
|
||||
// authenticated spec reads, and that is load-bearing rather than tidy. The
|
||||
// switch is stamped on the session row (see "the nav offers only what
|
||||
// sharing covers" below, which relies on exactly that), so switching the
|
||||
// shared jar would switch it for every spec holding the same cookie — and
|
||||
// the suite runs fully parallel. The damage lands somewhere else entirely:
|
||||
// a settings surface painting "Not part of shared access", an axe scan
|
||||
// reading a banner its spec never asked for. Same account, different
|
||||
// session, no reach outside this file.
|
||||
test.use({ storageState: DELEGATE_STORAGE_STATE_PATH });
|
||||
|
||||
// Desktop only, and for a reason that is about the fixture rather than the
|
||||
// layout: this journey mutates shared rows — one grant between two seeded
|
||||
// accounts — so two projects running it at once race each other, and the
|
||||
// second one's invitation is refused as a duplicate of the first's. Running
|
||||
// it once keeps every step's precondition true. The mobile bar reads the
|
||||
// same destination model as the sidebar, and that agreement is a unit
|
||||
// contract (`nav-model-shared-record.test.ts`) rather than something two
|
||||
// browser runs of the same script would establish.
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"shared-fixture journey — runs once",
|
||||
);
|
||||
});
|
||||
|
||||
let ownerContext: BrowserContext;
|
||||
let ownerPage: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ownerContext = await browser.newContext({
|
||||
storageState: OWNER_STORAGE_STATE_PATH,
|
||||
});
|
||||
ownerPage = await ownerContext.newPage();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await ownerContext.close();
|
||||
});
|
||||
|
||||
test("the owner invites, and the invitation confers nothing yet", async () => {
|
||||
await ownerPage.goto("/settings/access");
|
||||
|
||||
// The submit button is disabled until the controlled input holds a value,
|
||||
// so a `fill()` that lands before React has attached its listeners leaves
|
||||
// the form looking filled and behaving empty. Retry the pair until the
|
||||
// button agrees, rather than waiting a fixed time and hoping.
|
||||
const identifier = ownerPage.locator(
|
||||
'[data-slot="grant-invite-identifier"]',
|
||||
);
|
||||
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
|
||||
await expect(async () => {
|
||||
await identifier.fill(E2E_USER.username);
|
||||
await expect(submit).toBeEnabled({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
await submit.click();
|
||||
|
||||
const row = ownerPage
|
||||
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
||||
.first();
|
||||
await expect(row).toBeVisible();
|
||||
// Pending, not active. The state is the server's answer, rendered.
|
||||
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
|
||||
});
|
||||
|
||||
test("the delegate is offered nothing until they accept", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/access");
|
||||
|
||||
const row = page
|
||||
.locator('[data-slot="grants-received-list"] [data-slot="grant-row"]')
|
||||
.first();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
|
||||
|
||||
// No switcher yet: `accountAccess.canSwitch` is false while the only
|
||||
// grant is pending, and the menu binds that boolean.
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-trigger"]'),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.locator('[data-slot="grant-accept"]').click();
|
||||
await expect(row).toHaveAttribute("data-grant-state", "ACTIVE");
|
||||
});
|
||||
|
||||
test("the switcher appears once the grant is live", async ({ page }) => {
|
||||
await page.goto("/settings/access");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator('[data-slot="account-switcher-entry"]');
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toContainText(E2E_OWNER.username);
|
||||
// The way home is in the same menu as the way in.
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-own"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("switching shows the banner and never the delegate's own data", async ({
|
||||
page,
|
||||
}) => {
|
||||
await watchForText(page, DELEGATE_MARKER);
|
||||
|
||||
// Positive control. If the observer cannot see the marker on the page
|
||||
// that definitely shows it, its silence later means nothing.
|
||||
await page.goto("/measurements");
|
||||
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
||||
expect(await sawText(page)).toBe(true);
|
||||
|
||||
// Let the offline persister flush the delegate's own dashboard to
|
||||
// IndexedDB (1 s debounce). That snapshot is the stale-paint source this
|
||||
// whole assertion is aimed at — without it the check would pass against a
|
||||
// disk that was empty anyway.
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await openSwitcher(page);
|
||||
await page.locator('[data-slot="account-switcher-entry"]').click();
|
||||
|
||||
// Wait for CONTENT, and for content the assertion is not about: the
|
||||
// banner naming the owner. A skeleton does not satisfy it, and it says
|
||||
// nothing about the marker either way.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(E2E_OWNER.username);
|
||||
|
||||
// The document showing the banner has never shown the delegate's own
|
||||
// number. The latch was armed by that document's own init script, so this
|
||||
// covers its whole life rather than the instant it is read. See the
|
||||
// header for what this line can and cannot be made to fail on.
|
||||
expect(await sawText(page)).toBe(false);
|
||||
|
||||
// And it stays false across a navigation inside the record.
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
expect(await sawText(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("the nav offers only what sharing covers", async ({ page }) => {
|
||||
// A fresh page fixture, and still inside the record: the switch lives on
|
||||
// the session row rather than in the tab, so every tab of that browser is
|
||||
// switched at once. That is the property this navigation quietly proves.
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The record itself is still there…
|
||||
await expect(page.locator('a[href="/measurements"]').first()).toBeVisible();
|
||||
// …and everything that configures the account around it is not.
|
||||
await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/coach"]')).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/insights"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a surface sharing does not cover says so", async ({ page }) => {
|
||||
// Reached by bookmark: the nav entry is gone, the URL still resolves.
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable-leave"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("revoking from the owner's browser drops the delegate out", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The owner ends it from their own session, in their own context.
|
||||
await ownerPage.goto("/settings/access");
|
||||
await ownerPage.locator('[data-slot="grant-revoke"]').first().click();
|
||||
await ownerPage
|
||||
.getByRole("button", { name: "End access", exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
ownerPage
|
||||
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
||||
.first(),
|
||||
).toHaveAttribute("data-grant-state", "REVOKED");
|
||||
|
||||
// The delegate's next navigation lands back in their own account rather
|
||||
// than on a wall of refusals: the revoke cleared their acting session in
|
||||
// the same transaction that stamped the row.
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
// Their own record, back — which is also the proof that the page did not
|
||||
// simply fail to load.
|
||||
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("the ended grant stays on the record as history", async () => {
|
||||
await ownerPage.goto("/settings/access");
|
||||
const row = ownerPage
|
||||
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
||||
.first();
|
||||
// Revocation never deletes. "Who had access, from when to when, and who
|
||||
// ended it" is the question the panel exists to answer.
|
||||
await expect(row).toHaveAttribute("data-grant-state", "REVOKED");
|
||||
await expect(row).toContainText("You ended it");
|
||||
});
|
||||
});
|
||||
+204
-48
@@ -50,6 +50,61 @@ export const E2E_USER = {
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.36.0 — the SECOND account, so account sharing has two sides to test.
|
||||
*
|
||||
* Sharing is the one feature in the product that cannot be exercised by one
|
||||
* person: somebody has to own a record and somebody else has to be given it.
|
||||
* This account owns the record; `E2E_USER` is the delegate throughout the
|
||||
* sharing spec.
|
||||
*/
|
||||
export const E2E_OWNER = {
|
||||
email: "e2e-owner@healthlog.test",
|
||||
username: "e2e-owner",
|
||||
password: "Qv7bR2n!Ks9wLd4T",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Weights seeded one per account, chosen so neither can be mistaken for the
|
||||
* other in rendered markup and neither collides with a value any other spec
|
||||
* writes.
|
||||
*
|
||||
* The delegate's number is the whole point of the stale-paint check: after
|
||||
* switching into the owner's record it must appear NOWHERE, at no moment. A
|
||||
* spec that only waited for the owner's data to arrive would prove nothing
|
||||
* about what was on screen in between.
|
||||
*/
|
||||
export const E2E_DELEGATE_MARKER_KG = 77.7;
|
||||
export const E2E_OWNER_MARKER_KG = 63.3;
|
||||
|
||||
export const OWNER_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateOwner.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* A SECOND cookie jar for the same delegate account, and the reason it exists
|
||||
* is the one property that makes account switching work at all: the switch is
|
||||
* stamped on the session row, not held in the tab. Every session opened from
|
||||
* `STORAGE_STATE_PATH` is therefore the same session — so a spec that switches
|
||||
* that jar into somebody else's record switches it for every spec that
|
||||
* authenticates with it, whichever of them happens to be mid-navigation on the
|
||||
* other worker.
|
||||
*
|
||||
* The symptom is a wandering one: `/settings/ai` painting "Not part of shared
|
||||
* access", an axe scan reading a banner nobody asked for, a `waitForResponse`
|
||||
* on a request the refused surface never sends. Nothing in the failing spec is
|
||||
* wrong and nothing in its file explains it.
|
||||
*
|
||||
* So the journey that switches gets its own login, and the shared jar is never
|
||||
* pointed anywhere. Same account, different session row, no overlap.
|
||||
*/
|
||||
export const DELEGATE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateDelegate.json",
|
||||
);
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, {
|
||||
memoryCost: 19456,
|
||||
@@ -179,6 +234,85 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
[cuid(), now, E2E_USER.username],
|
||||
);
|
||||
|
||||
// ── v1.36.0 — the sharing fixture ────────────────────────────────────
|
||||
//
|
||||
// A second account, one distinctive weight in each record, and a clean
|
||||
// grant table. The reset matters as much as the seed: grants and switch
|
||||
// stamps outlive a run, and a second run starting with the delegate
|
||||
// already inside the owner's record would skip the invitation half of
|
||||
// the journey while still going green.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role,
|
||||
created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_OWNER.username,
|
||||
E2E_OWNER.email,
|
||||
await hashPassword(E2E_OWNER.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// Every grant between the pair, and any switch stamp either of them is
|
||||
// carrying. Deleted rather than revoked: this is fixture hygiene, not a
|
||||
// lifecycle transition, and a revoked row would still show in the panel.
|
||||
await pool.query(
|
||||
`DELETE FROM account_grants
|
||||
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
|
||||
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||||
[[E2E_USER.username, E2E_OWNER.username]],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE sessions SET acting_as_user_id = NULL
|
||||
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||||
[[E2E_USER.username, E2E_OWNER.username]],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key LIKE 'sharing:%'`,
|
||||
);
|
||||
|
||||
// The login bucket, for the same reason. This setup signs in THREE times
|
||||
// now (the shared jar, the owner, and the sharing journey's own delegate
|
||||
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
|
||||
// local runs in a row would otherwise end with a 429 from the fixture
|
||||
// rather than a failure from the product. Only the auth surfaces' own
|
||||
// buckets are cleared; nothing else in the table is touched.
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
|
||||
// One marker weight per record. Re-seeded by delete-then-insert so a
|
||||
// re-run cannot accumulate duplicates and so an edited constant takes
|
||||
// effect rather than sitting beside the old value.
|
||||
for (const [username, kg] of [
|
||||
[E2E_USER.username, E2E_DELEGATE_MARKER_KG],
|
||||
[E2E_OWNER.username, E2E_OWNER_MARKER_KG],
|
||||
] as const) {
|
||||
await pool.query(
|
||||
`DELETE FROM measurements
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)
|
||||
AND external_id = 'e2e-sharing-marker'`,
|
||||
[username],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at,
|
||||
external_id, created_at, updated_at)
|
||||
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
|
||||
'e2e-sharing-marker', $3, $3
|
||||
FROM users u WHERE u.username = $4`,
|
||||
[cuid(), kg, now, username],
|
||||
);
|
||||
}
|
||||
|
||||
// Console (instead of structured logging) is intentional here —
|
||||
// global-setup runs outside the app's logging context, and the
|
||||
// line is useful when debugging a CI failure where the seed didn't
|
||||
@@ -190,60 +324,82 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Login once and persist the resulting cookie jar so every spec can
|
||||
// reuse it via `playwright.config.ts → use.storageState`. This is the
|
||||
// documented Playwright pattern (`docs/auth.md`) and the only way to
|
||||
// avoid the per-spec login + rate-limit dance.
|
||||
// Log the fixture accounts in here and persist their cookie jars so specs
|
||||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec
|
||||
// login + rate-limit dance.
|
||||
const baseURL =
|
||||
config.projects[0]?.use.baseURL ??
|
||||
process.env.E2E_BASE_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
// The shared jar every authenticated spec reads.
|
||||
await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH);
|
||||
|
||||
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant
|
||||
// signed in at once, and the login endpoint is IP-rate-limited, so the owner
|
||||
// is logged in here rather than inside the spec.
|
||||
await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH);
|
||||
|
||||
// The delegate's own jar for that journey. Same account as the shared one,
|
||||
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
|
||||
// what happens when the journey switches the shared row instead.
|
||||
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log one account in and write its cookie jar to `path`.
|
||||
*
|
||||
* Each call mints a fresh session row, so two jars for the same account are
|
||||
* two independent browser sessions — which is the whole point of the delegate
|
||||
* jar above.
|
||||
*
|
||||
* Only the cookies the auth shell needs survive the filter. v1.4.22 C4 added
|
||||
* `hl_onboarding`: the login route sets it to "pending" for a new account and
|
||||
* DELETES it for an already-onboarded one, so for these pre-seeded accounts it
|
||||
* is absent and the proxy passes the dashboard through.
|
||||
*
|
||||
* v1.27.11 — the locale cookie is pinned to English rather than inherited. The
|
||||
* server honours `User.locale` when the cookie is absent (the ITP fix), and
|
||||
* the locale-switch spec mirrors its German pick into the SHARED account's
|
||||
* profile — without this cookie every spec running after it would render
|
||||
* German and the English-string assertions would fail. The cookie sits at the
|
||||
* top of the resolution ladder, so each spec context stays deterministically
|
||||
* English; locale-switch overrides it inside its own context only.
|
||||
*/
|
||||
async function captureAuthState(
|
||||
baseURL: string,
|
||||
account: { username: string; password: string },
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const ctx = await playwrightRequest.newContext({ baseURL });
|
||||
const res = await ctx.post("/api/auth/login", {
|
||||
data: {
|
||||
email: E2E_USER.username,
|
||||
password: E2E_USER.password,
|
||||
},
|
||||
});
|
||||
if (res.status() !== 200) {
|
||||
const body = await res.text();
|
||||
throw new Error(
|
||||
`[e2e/global-setup] login failed: HTTP ${res.status()} — ${body.slice(0, 200)}`,
|
||||
try {
|
||||
const res = await ctx.post("/api/auth/login", {
|
||||
data: { email: account.username, password: account.password },
|
||||
});
|
||||
if (res.status() !== 200) {
|
||||
const body = await res.text();
|
||||
throw new Error(
|
||||
`[e2e/global-setup] login failed for ${account.username}: HTTP ${res.status()} — ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
const state = await ctx.storageState();
|
||||
state.cookies = state.cookies.filter((c) =>
|
||||
["healthlog_session", "hl_onboarding"].includes(c.name),
|
||||
);
|
||||
state.cookies.push({
|
||||
name: "healthlog-locale",
|
||||
value: "en",
|
||||
domain: new URL(baseURL).hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
await writeFile(path, JSON.stringify(state, null, 2));
|
||||
console.log(`[e2e/global-setup] auth state captured to ${path}`);
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
const state = await ctx.storageState();
|
||||
// Strip any cookies we don't recognise so storageState only carries
|
||||
// what the auth shell needs. v1.4.22 C4 added `hl_onboarding` — the
|
||||
// login route sets it to "pending" for new users and DELETES it for
|
||||
// already-onboarded ones, so for our pre-seeded completed e2e user
|
||||
// the cookie is absent and the proxy passes the dashboard through.
|
||||
state.cookies = state.cookies.filter((c) =>
|
||||
["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name),
|
||||
);
|
||||
// v1.27.11 — pin the shared auth state to English via an explicit locale
|
||||
// cookie. The server now honours `User.locale` when the cookie is absent
|
||||
// (the ITP fix), and the locale-switch spec mirrors its German pick into
|
||||
// the SHARED e2e user's profile — without this cookie every spec running
|
||||
// after it would render German and the English-string assertions fail.
|
||||
// The cookie sits at the top of the resolution ladder, so each spec
|
||||
// context stays deterministically English; locale-switch overrides it
|
||||
// inside its own context only.
|
||||
const base = new URL(baseURL);
|
||||
state.cookies = state.cookies.filter((c) => c.name !== "healthlog-locale");
|
||||
state.cookies.push({
|
||||
name: "healthlog-locale",
|
||||
value: "en",
|
||||
domain: base.hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
|
||||
await ctx.dispose();
|
||||
console.log(
|
||||
`[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`,
|
||||
);
|
||||
}
|
||||
@@ -6,4 +6,12 @@
|
||||
* specific to the spec-side (e.g. multi-user scenarios that need
|
||||
* fresh logins) have a natural home.
|
||||
*/
|
||||
export { E2E_USER, STORAGE_STATE_PATH } from "./global-setup";
|
||||
export {
|
||||
E2E_USER,
|
||||
E2E_OWNER,
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER_MARKER_KG,
|
||||
STORAGE_STATE_PATH,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
} from "./global-setup";
|
||||
Reference in new issue
Block a user