diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 80d3765d3..8fb94174d 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,34 @@ export const E2E_OWNER = { role: "USER", } as const; +/** A separate delegate session for the eight scoped-record browser journeys. */ +export const E2E_SCOPE_DELEGATE = { + email: "e2e-scope-delegate@healthlog.test", + username: "e2e-scope-delegate", + password: "Qz8!Vp4rL2nX7mKs", + role: "USER", +} as const; + +/** One non-personal target record per closed sharing domain. */ +export const E2E_SCOPE_RECORDS = [ + { + domain: "measurements", + username: "e2e-scope-measurements", + href: "/measurements", + }, + { + domain: "medications", + username: "e2e-scope-medications", + href: "/medications", + }, + { domain: "labs", username: "e2e-scope-labs", href: "/labs" }, + { domain: "profile", username: "e2e-scope-profile", href: "/profile" }, + { domain: "illness", username: "e2e-scope-illness", href: "/illness" }, + { domain: "mind", username: "e2e-scope-mind", href: "/mood" }, + { domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" }, + { domain: "documents", username: "e2e-scope-documents", href: "/documents" }, +] 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 @@ -105,6 +133,11 @@ export const DELEGATE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateDelegate.json", ); +export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeDelegate.json", +); + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -201,6 +234,110 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The scoped-record journey uses a dedicated delegate so its preseeded + // grants cannot affect the invitation lifecycle exercised by the sharing + // journey above. Its own module preferences are explicitly off: a target + // record's resolved scope, rather than the actor's preferences, must keep + // each granted doorway visible. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb) + 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, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_SCOPE_DELEGATE.username, + E2E_SCOPE_DELEGATE.email, + await hashPassword(E2E_SCOPE_DELEGATE.password), + now, + JSON.stringify({ + medications: false, + labs: false, + illness: false, + mood: false, + mentalHealth: false, + inboundDocuments: false, + }), + ], + ); + + await pool.query( + `DELETE FROM account_grants + WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_SCOPE_DELEGATE.username], + ); + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_SCOPE_DELEGATE.username], + ); + + for (const record of E2E_SCOPE_RECORDS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $4, true, $5::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + updated_at = EXCLUDED.updated_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, + 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, 'READ', $2::jsonb, $3, $3, $3 + FROM users owner, users delegate + WHERE owner.username = $4 AND delegate.username = $5`, + [ + cuid(), + JSON.stringify([record.domain]), + 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 + (id, user_id, cycle_tracking_enabled, created_at, updated_at) + SELECT $1, u.id, true, $2, $2 + FROM users u WHERE u.username = $3 + ON CONFLICT (user_id) DO UPDATE SET + cycle_tracking_enabled = true, + updated_at = EXCLUDED.updated_at`, + [cuid(), now, "e2e-scope-cycle"], + ); + // Repeated local E2E runs reuse the same seeded account and database. // Its owner-scoped share-link bucket lasts an hour, so otherwise the third // run can start above the 20-operation ceiling and fail before exercising @@ -281,7 +418,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in THREE times + // The login bucket, for the same reason. This setup signs in FOUR 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 @@ -345,6 +482,12 @@ export default async function globalSetup(config: FullConfig): Promise { // 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); + + await captureAuthState( + baseURL, + E2E_SCOPE_DELEGATE, + SCOPE_DELEGATE_STORAGE_STATE_PATH, + ); } /** diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9ae2f7a18..5a72d271f 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,9 +9,12 @@ export { E2E_USER, E2E_OWNER, + E2E_SCOPE_DELEGATE, + E2E_SCOPE_RECORDS, E2E_DELEGATE_MARKER_KG, E2E_OWNER_MARKER_KG, STORAGE_STATE_PATH, OWNER_STORAGE_STATE_PATH, DELEGATE_STORAGE_STATE_PATH, + SCOPE_DELEGATE_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 5f4eb88c5..db8f38a57 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -1,39 +1,74 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; +import { + E2E_SCOPE_RECORDS, + SCOPE_DELEGATE_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; -import { RELEASE_JOURNEYS } from "../tests/fixtures/v137/e2e-journeys"; +const DOMAIN_READS = { + measurements: "/api/measurements", + medications: "/api/medications", + labs: "/api/labs", + profile: "/api/profile/summary", + illness: "/api/illness/episodes", + mind: "/api/mood-entries", + cycle: "/api/cycle/calendar", + documents: "/api/documents/inbound", +} as const; -test.describe("sharing release journey anchors", () => { - for (const journey of RELEASE_JOURNEYS) { - test(`anchor: ${journey.name}`, () => { - expect(journey.contract).toBe("inventory-only"); +async function openSwitcher(page: Page) { + 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("scoped sharing browser journeys", () => { + test.describe.configure({ mode: "serial" }); + test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "scoped sharing seed has one session and runs once", + ); + }); + + for (const record of E2E_SCOPE_RECORDS) { + test(`opens only the ${record.domain} record doorway`, async ({ page }) => { + await page.goto("/"); + await openSwitcher(page); + + const entry = page + .locator('[data-slot="account-switcher-entry"]') + .filter({ hasText: record.username }); + await expect(entry).toHaveCount(1); + + const read = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === DOMAIN_READS[record.domain], + ); + await entry.click(); + + await expect(page).toHaveURL(new RegExp(`${record.href}$`)); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toContainText(record.username); + await expect( + page.locator(`a[href="${record.href}"]`).first(), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).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); }); } - - test("adult levels and lifecycle", () => { - const adultJourneyNames = RELEASE_JOURNEYS.filter((journey) => - [ - "adult-full-read-scoped-read-write-and-manage", - "manage-mutation-activity-and-fenced-settings", - ].includes(journey.name), - ).map((journey) => journey.name); - - expect(adultJourneyNames).toEqual([ - "adult-full-read-scoped-read-write-and-manage", - "manage-mutation-activity-and-fenced-settings", - ]); - }); - - test("managed profile lifecycle", () => { - const managedJourneyNames = RELEASE_JOURNEYS.filter((journey) => - [ - "managed-profile-creation-delegation-and-revocation", - "manage-mutation-activity-and-fenced-settings", - ].includes(journey.name), - ).map((journey) => journey.name); - - expect(managedJourneyNames).toEqual([ - "managed-profile-creation-delegation-and-revocation", - "manage-mutation-activity-and-fenced-settings", - ]); - }); });