test(02-12): exercise scoped record doorways

- seed isolated records for all eight shared domains\n- verify each browser switch lands on its scoped read surface
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:12:33 +02:00
1 parent 7104578d27
commit 407f7cc044
3 files changed
+215 -34

No files matched your search

+144 -1
View File
@@ -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<string> {
return hash(password, {
memoryCost: 19456,
@@ -201,6 +234,110 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// 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<void> {
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<void> {
// 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,
);
}
/**
+3
View File
@@ -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";
+68 -33
View File
@@ -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",
]);
});
});