mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
7104578d27
commit
407f7cc044
3 files changed
+215
-34
No files matched your search
+144
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user