mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(sharing): drive the managed-record capabilities over the routes and the browser
The integration leg reads GET /api/auth/me as a guardian acting on a managed profile and asserts both lists, lands a mood entry and an allergy on the profile's record, is refused a vault write, and shows a WRITE delegate writable sections without manageable ones together with the refused mood entry the list withheld. The browser leg opens the managed profile, adds a mood entry through the control that used to be hidden and asserts the POST's status, and finds the owner-only sentence and no upload control on the documents page. The spec gets its own session jar and runs in the desktop project only, like every other spec that moves the record selector.
This commit is contained in:
1 parent
e95612e3d3
commit
4b3e71570c
5 files changed
+400
No files matched your search
@@ -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<void> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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<ActiveEntry> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user