mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
b231a7892c
commit
4999c786e3
3 files changed
+311
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user