diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 44ac59a20..14f0343ad 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -198,6 +198,17 @@ export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScopeA11y.json", ); +/** + * v1.38.12 — the managed-record capabilities journey's own jar. Same scope + * delegate, its own session row, for the reason every switching spec has + * one: it moves the record selector, and two specs driving one row from two + * workers would test their interference rather than the controls. + */ +export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeCapabilities.json", +); + /** * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as @@ -739,6 +750,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); // v1.37.0 — one jar per switching spec. See the block comment on // FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9fd153d33..024f9199e 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -21,6 +21,7 @@ export { DELEGATE_STORAGE_STATE_PATH, SCOPE_DELEGATE_STORAGE_STATE_PATH, SCOPE_A11Y_STORAGE_STATE_PATH, + SCOPE_CAPABILITIES_STORAGE_STATE_PATH, FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts new file mode 100644 index 000000000..29650bf10 --- /dev/null +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -0,0 +1,140 @@ +/** + * v1.38.12 — the controls a guardian sees inside a managed profile (#939). + * + * A person acting on a managed profile holds a MANAGE grant, and the server + * has accepted their mood entries, screeners, visits, allergies and lab edits + * since v1.37.0. The interface withheld every one of those controls anyway, + * because the client answered `canManage: false` for every shared record. It + * answers per section now, from the two lists `GET /api/auth/me` publishes. + * + * Two pages, one each way: + * + * - Mood is a section whose create answers at MANAGE, so the add control + * is there and an entry can be added — asserted on the POST's status, not + * on the paint, because the paint is what lied before. + * - Documents is the section with no delegated write route, so the upload + * control is absent and the page says why, in a sentence, where the + * control would have been. + * + * Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are + * the same ones, for the same reasons written there. + */ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + E2E_LEVEL_RECORDS, + SCOPE_CAPABILITIES_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; + +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(); +} + +/** + * Run `act` and wait for the document it replaces to be gone. See the + * v1.37.0 spec for why waiting on the banner alone settles nothing. + */ +async function withDocumentReplacement( + page: Page, + act: () => Promise, +): Promise { + await page.evaluate(() => { + (window as Window & { __hlNavToken?: true }).__hlNavToken = true; + }); + await act(); + await page.waitForFunction( + () => + (window as Window & { __hlNavToken?: true }).__hlNavToken === undefined, + undefined, + { timeout: 30_000 }, + ); +} + +async function openRecord(page: Page, username: string) { + await page.goto("/"); + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); + await expect(entry).toHaveCount(1); + await withDocumentReplacement(page, () => entry.click()); + await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({ + timeout: 30_000, + }); +} + +async function leaveRecord(page: Page) { + await withDocumentReplacement(page, () => + page.locator('[data-slot="shared-record-banner-exit"]').click(), + ); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + { timeout: 30_000 }, + ); +} + +test.describe.serial("a guardian's controls inside a managed profile", () => { + // Its own session row: this journey moves the record selector, and the + // scoped-sharing journey drives the same account from another worker. + test.use({ storageState: SCOPE_CAPABILITIES_STORAGE_STATE_PATH }); + + const managed = E2E_LEVEL_RECORDS.find( + (record) => record.recordKind === "managed", + ); + if (!managed) throw new Error("managed record fixture is missing"); + + test("offers the mood add control and lands the entry", async ({ page }) => { + await openRecord(page, managed.username); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + + await page.goto("/mood"); + const add = page.locator('[data-slot="mood-add-entry"]'); + await expect(add).toBeVisible({ timeout: 30_000 }); + await add.click(); + + const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]'); + await expect(face).toBeVisible(); + await face.click(); + + const posted = page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/mood-entries(\?|$)/.test(response.url()), + { timeout: 30_000 }, + ); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const response = await posted; + // The route, not the paint: a control that renders and 403s is the exact + // failure this release ends. + expect(response.status()).toBe(201); + await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({ + timeout: 30_000, + }); + + await leaveRecord(page); + }); + + test("withholds the vault's upload control and says why", async ({ + page, + }) => { + await openRecord(page, managed.username); + + await page.goto("/documents"); + const note = page.locator('[data-slot="documents-owner-only"]'); + await expect(note).toBeVisible({ timeout: 30_000 }); + await expect(note).toHaveText( + "Documents can only be added by the record owner.", + ); + await expect(page.getByRole("button", { name: "Upload" })).toHaveCount(0); + await expect(page.locator('input[type="file"]')).toHaveCount(0); + + await leaveRecord(page); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 0fb132700..928caf004 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -117,6 +117,7 @@ export default defineConfig({ testIgnore: [ "v137-sharing-managed-profiles.spec.ts", "v137-sharing-managed-profiles-a11y.spec.ts", + "v138-managed-record-capabilities.spec.ts", // v1.37.0 — every spec that MOVES a session's record selector runs in // exactly one project. // diff --git a/tests/integration/managed-record-capabilities.test.ts b/tests/integration/managed-record-capabilities.test.ts new file mode 100644 index 000000000..5939af474 --- /dev/null +++ b/tests/integration/managed-record-capabilities.test.ts @@ -0,0 +1,246 @@ +/** + * v1.38.12 — what a delegate is told they may do, and what the routes then do. + * + * `GET /api/auth/me` publishes two lists on the active entry, + * `writableDomains` and `manageableDomains`, and every add / edit / delete + * control in a shared record reads them. The lists are worth nothing unless + * the routes agree with them, so this file drives both ends over the real + * route handlers, the real grant table and real Postgres: + * + * 1. a guardian acting on a managed profile reads the payload and gets + * every section with a delegated route as manageable, and never the + * vault; + * 2. the same guardian posts a mood entry and an allergy — the two writes + * #939 was reported on — and both land on the PROFILE's record; + * 3. the same guardian is refused a vault write, which is what the empty + * answer for `documents` promised; + * 4. a WRITE delegate on an adult's record reads writable sections and no + * manageable ones, and is refused the mood entry the list withheld. + * + * The lists are compared against `delegatedDomains`, the helper the resolver + * itself runs, so a change to the table shows up here as a change in what the + * routes must then honour, not as a fixture to update. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `caps-${suffix}`, + email: `caps-${suffix}@example.test`, + displayName: `Caps ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + onboardingCompletedAt: new Date(), + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +interface ActiveEntry { + accountId: string; + level: "read" | "write" | "manage"; + recordKind: "shared" | "managed"; + writableDomains: ShareDomain[]; + manageableDomains: ShareDomain[]; +} + +async function readActive(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { accountAccess: { active: ActiveEntry | null } }; + }; + const active = body.data.accountAccess.active; + if (active === null) throw new Error("expected a switched payload"); + return active; +} + +function jsonRequest(url: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${url}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function postMood() { + const { POST } = await import("@/app/api/mood-entries/route"); + return POST(jsonRequest("/api/mood-entries", { mood: "GUT" })); +} + +async function postAllergy() { + const { POST } = await import("@/app/api/allergies/route"); + return POST( + jsonRequest("/api/allergies", { + substance: "Penicillin", + category: "MEDICATION", + severity: "SEVERE", + }), + ); +} + +async function postDocumentsBulk() { + const { POST } = await import("@/app/api/documents/inbound/bulk/route"); + return POST( + jsonRequest("/api/documents/inbound/bulk", { + ids: [], + action: "setKind", + kind: "OTHER", + }), + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + counter = 0; +}); + +describe("a guardian acting on a managed profile", () => { + async function guardianInside() { + const guardian = await makeUser("guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const session = await signIn(guardian.id); + await switchSessionTo(session.id, profile.id); + return { guardian, profile }; + } + + it("is told every section with a delegated route is manageable, and the vault is not", async () => { + const { profile } = await guardianInside(); + const active = await readActive(); + + expect(active.accountId).toBe(profile.id); + expect(active.level).toBe("manage"); + expect(active.recordKind).toBe("managed"); + expect(active.writableDomains).toEqual( + delegatedDomains("manage", null, "write"), + ); + expect(active.manageableDomains).toEqual( + delegatedDomains("manage", null, "manage"), + ); + // The sections #939 named, by name, so the list is not only self-consistent. + for (const domain of ["mind", "profile", "labs", "measurements"] as const) { + expect(active.manageableDomains, domain).toContain(domain); + } + expect(active.manageableDomains).not.toContain("documents"); + expect(active.writableDomains).not.toContain("documents"); + }); + + it("lands a mood entry and an allergy on the profile's record", async () => { + const { guardian, profile } = await guardianInside(); + + const mood = await postMood(); + expect(mood.status).toBe(201); + const moodRows = await getPrismaClient().moodEntry.findMany({ + where: { userId: profile.id }, + }); + expect(moodRows).toHaveLength(1); + expect( + await getPrismaClient().moodEntry.count({ + where: { userId: guardian.id }, + }), + ).toBe(0); + + const allergy = await postAllergy(); + expect(allergy.status).toBe(201); + const allergyRows = await getPrismaClient().allergy.findMany({ + where: { userId: profile.id }, + }); + expect(allergyRows).toHaveLength(1); + expect(allergyRows[0]?.substance).toBe("Penicillin"); + }); + + it("is refused a vault write, as the empty documents answer promised", async () => { + await guardianInside(); + const res = await postDocumentsBulk(); + expect(res.status).toBe(403); + }); +}); + +describe("a WRITE delegate on an adult's record", () => { + it("reads writable sections without manageable ones, and is refused the mood entry", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "WRITE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + + const active = await readActive(); + expect(active.level).toBe("write"); + expect(active.writableDomains).toEqual( + delegatedDomains("write", null, "write"), + ); + expect(active.writableDomains).toContain("measurements"); + expect(active.writableDomains).not.toContain("mind"); + expect(active.manageableDomains).toEqual([]); + + // The route agrees with the list: a mood entry is a MANAGE create. + const mood = await postMood(); + expect(mood.status).toBe(403); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }), + ).toBe(0); + }); +});