diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 24cec4db6..697b373fb 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -93,6 +93,33 @@ export const E2E_SCOPE_RECORDS = [ { domain: "documents", username: "e2e-scope-documents", href: "/documents" }, ] as const; +/** + * Whole-record grants used to prove the three adult access levels and the + * separate managed-profile boundary in a real browser session. + */ +export const E2E_LEVEL_RECORDS = [ + { + username: "e2e-level-read", + access: "READ", + recordKind: "shared", + }, + { + username: "e2e-level-write", + access: "WRITE", + recordKind: "shared", + }, + { + username: "e2e-level-manage", + access: "MANAGE", + recordKind: "shared", + }, + { + username: "e2e-level-managed", + access: "MANAGE", + recordKind: "managed", + }, +] 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 @@ -331,6 +358,59 @@ export default async function globalSetup(config: FullConfig): Promise { ); } + // Whole-record fixtures intentionally sit beside the narrow records + // above. They make the browser prove the presentation and mutation + // boundaries of READ, WRITE, MANAGE, and a guardian-managed record + // without changing the invitation lifecycle fixture below. + for (const record of E2E_LEVEL_RECORDS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + managed_profile_at, onboarding_completed_at, + onboarding_tour_completed, module_preferences_json) + VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = NULL, + updated_at = EXCLUDED.updated_at, + managed_profile_at = EXCLUDED.managed_profile_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + record.username, + `${record.username}@healthlog.test`, + now, + record.recordKind === "managed" ? now : null, + JSON.stringify({ + medications: true, + labs: true, + illness: true, + mood: true, + mentalHealth: true, + inboundDocuments: true, + }), + ], + ); + + await pool.query( + `INSERT INTO account_grants + (id, grantor_id, grantee_id, access, scope_json, invited_at, + accepted_at, created_at) + SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3 + FROM users owner, users delegate + WHERE owner.username = $4 AND delegate.username = $5`, + [ + cuid(), + record.access, + now, + record.username, + E2E_SCOPE_DELEGATE.username, + ], + ); + } + // Cycle is delegated to its profile gate rather than module preferences. await pool.query( `INSERT INTO cycle_profiles diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 837e6179d..0b97ecc4e 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -11,6 +11,7 @@ export { E2E_OWNER, E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, + E2E_LEVEL_RECORDS, E2E_DELEGATE_MARKER_KG, E2E_OWNER_MARKER_KG, STORAGE_STATE_PATH, diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index b83e15631..5239de994 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { + E2E_LEVEL_RECORDS, E2E_SCOPE_RECORDS, SCOPE_DELEGATE_STORAGE_STATE_PATH, } from "./setup/test-helpers"; @@ -25,6 +26,31 @@ async function openSwitcher(page: Page) { ).toBeVisible(); } +async function openRecord(page: Page, username: string) { + await page.goto("/"); + await openSwitcher(page); + + const entry = page + .locator('[data-slot="account-switcher-entry"]') + .filter({ hasText: username }); + await expect(entry).toHaveCount(1); + const accountId = await entry.getAttribute("data-account-id"); + if (accountId === null) { + throw new Error(`Account switcher entry for ${username} has no account id`); + } + const accessLevel = await entry.getAttribute("data-access-level"); + const recordKind = await entry.getAttribute("data-record-kind"); + await entry.click(); + return { accountId, accessLevel, recordKind }; +} + +async function leaveRecord(page: Page) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + ); +} + test.describe("scoped sharing browser journeys", () => { test.describe.configure({ mode: "serial" }); test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH }); @@ -58,10 +84,210 @@ test.describe("scoped sharing browser journeys", () => { ).toHaveCount(0); await read; - await page.locator('[data-slot="shared-record-banner-exit"]').click(); - await expect( - page.locator('[data-slot="shared-record-banner"]'), - ).toHaveCount(0); + await leaveRecord(page); }); } + + test("keeps an adult READ grant read-only and outside Settings", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === "READ", + ); + if (!record) throw new Error("READ fixture is missing"); + + const { accessLevel, recordKind } = await openRecord(page, record.username); + expect(accessLevel).toBe("view"); + expect(recordKind).toBe("shared"); + + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-access-level", "view"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + await expect(banner).toContainText(record.username); + + await page.goto("/measurements"); + await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("lets an adult WRITE grant add only to the selected record", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === "WRITE", + ); + if (!record) throw new Error("WRITE fixture is missing"); + + const { accountId, accessLevel, recordKind } = await openRecord( + page, + record.username, + ); + expect(accessLevel).toBe("view-and-add"); + expect(recordKind).toBe("shared"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-account-id", accountId); + await expect(banner).toHaveAttribute("data-access-level", "view-and-add"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + + await page.goto("/measurements"); + await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible(); + const post = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/measurements", + ); + await page.locator('[data-slot="measurement-add"]').click(); + await page.locator("#sys").fill("124"); + await page.locator("#dia").fill("78"); + await page.getByRole("button", { name: /^save$/i }).click(); + expect((await post).status()).toBeLessThan(300); + + const read = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/measurements", + ); + await page.reload(); + const payload = await read; + expect(JSON.stringify(await payload.json())).toContain("124"); + await expect(banner).toHaveAttribute("data-account-id", accountId); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("opens MANAGE-only generated reads without opening adult Settings", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => + candidate.access === "MANAGE" && candidate.recordKind === "shared", + ); + if (!record) throw new Error("adult MANAGE fixture is missing"); + + const { accessLevel, recordKind } = await openRecord(page, record.username); + expect(accessLevel).toBe("manage"); + expect(recordKind).toBe("shared"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + + const generatedRead = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/dashboard/summary", + ); + await page.goto("/"); + expect((await generatedRead).status()).toBeLessThan(300); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toHaveCount(0); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("keeps managed Settings on the active record and integrations status-only", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.recordKind === "managed", + ); + if (!record) throw new Error("managed record fixture is missing"); + + const { accountId, accessLevel, recordKind } = await openRecord( + page, + record.username, + ); + expect(accessLevel).toBe("manage"); + expect(recordKind).toBe("managed"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-account-id", accountId); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + + await page.goto("/settings/account"); + const profile = page.locator('[data-record-settings-family="profile"]'); + await expect(profile).toBeVisible(); + await expect(profile).toHaveAttribute("data-record-id", accountId); + + const integrationRead = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === + "/api/record-settings/integrations", + ); + await page.goto("/settings/integrations"); + const integrationResponse = await integrationRead; + expect(integrationResponse.status()).toBeLessThan(300); + const integrationPayload = (await integrationResponse.json()) as { + data?: { recordId?: string }; + }; + expect(integrationPayload.data?.recordId).toBe(accountId); + const integrations = page.locator( + '[data-record-settings-family="integrations"]', + ); + await expect(integrations).toBeVisible(); + await expect(integrations).toHaveAttribute("data-record-id", accountId); + await expect( + integrations.locator("button, input, select, textarea"), + ).toHaveCount(0); + + await page.goBack(); + await expect(profile).toHaveAttribute("data-record-id", accountId); + await page.goForward(); + await expect(integrations).toHaveAttribute("data-record-id", accountId); + await expect(banner).toHaveAttribute("data-account-id", accountId); + + await leaveRecord(page); + await page.goto("/measurements"); + await expect(banner).toHaveCount(0); + }); + + test("refuses direct ungranted routes before coach reads start", async ({ + page, + }) => { + await openRecord(page, "e2e-scope-measurements"); + const protectedCoachReads: string[] = []; + const trackCoachRead = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if ( + [ + "/api/insights/coach/nudge-status", + "/api/coach/about-me/questions", + "/api/auth/me/notification-prefs", + ].includes(path) + ) { + protectedCoachReads.push(path); + } + }; + page.on("request", trackCoachRead); + try { + await page.goto("/coach"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + expect(protectedCoachReads).toEqual([]); + + await page.goto("/settings/access"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + expect(protectedCoachReads).toEqual([]); + } finally { + page.off("request", trackCoachRead); + } + await leaveRecord(page); + }); });