test(02-12): exercise access-level record journeys

- Seed adult and managed whole-record grants for browser coverage\n- Verify scoped writes, settings boundaries, and refused coach reads
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:26:21 +02:00
1 parent b231a7892c
commit 4999c786e3
3 files changed
+311 -4

No files matched your search

+80
View File
@@ -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<void> {
);
}
// 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
+1
View File
@@ -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,
+230 -4
View File
@@ -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);
});
});