mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
docs(api): publish the account-access block and the selector header
Three additions to the contract, all of them things a native client cannot work out on its own. `GET /api/auth/me` enters the spec for the first time, and enters it loose: `accountAccess` is specified in full, the remaining preference fields are declared open rather than half-enumerated. The endpoint has never been in this document and its payload carries thirty-odd keys no contract has ever named; specifying a subset and closing the object would publish something the server does not keep. `X-HealthLog-Account` is published as a reusable parameter with no route referencing it, which is the honest state of the contract — the routes that read another account's record arrive with the surface migration and will reference it as they land. The header is live in the resolver today, and a client needs the description before it can be built against a tagged spec: the value comes from `accountAccess.accounts`, it is a selector and never a permission, and it must never ride the sync engine, device registration, or an auth call. `GET /api/account/activity` carries `retentionDays` in the response and says why: the window is operator-configurable, so a client that assumed 365 would tell somebody their record was never opened when the honest answer is "further back than this reaches". The parameter is defined in the sharing route module rather than the shared components block, so the boundary guard keeps holding the header's name to two files — the resolver that reads it, and the one place that publishes it.
This commit is contained in:
1 parent
04d30c2570
commit
883fd2d047
7 files changed
+866
-29
No files matched your search
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Account sharing, end to end: invite → accept → switch → banner → revoke.
|
||||
*
|
||||
* Two browser contexts, two real accounts, one real database. The owner
|
||||
* invites, the delegate accepts and opens the record, and the owner takes it
|
||||
* back — each step through the shipped UI rather than through the API, because
|
||||
* the half of this feature that can go wrong is the half a person touches.
|
||||
*
|
||||
* ## The stale-paint check
|
||||
*
|
||||
* The assertion this spec exists for is a NEGATIVE one, and it is deliberately
|
||||
* not "the new record's data arrives". A check that waits for the new data
|
||||
* proves nothing about what was on screen while it was arriving, and the
|
||||
* failure being guarded against is exactly that in-between frame: a dashboard
|
||||
* hydrated from the previous record's IndexedDB snapshot, painted under a
|
||||
* banner naming somebody else.
|
||||
*
|
||||
* So an init script installs a MutationObserver before any page script runs,
|
||||
* on every navigation including the full reload the switch performs, and it
|
||||
* latches the moment the delegate's own marker weight appears in the document.
|
||||
* The spec then reads the latch. Nothing about it is sampled or timed.
|
||||
*
|
||||
* The observer's positive control runs first: with the same script installed,
|
||||
* the delegate's own measurements page must trip the latch. Without that, "the
|
||||
* marker never appeared" and "the observer never worked" are the same sentence.
|
||||
*
|
||||
* ## What this spec cannot cover, stated rather than implied
|
||||
*
|
||||
* - **The service worker.** The Playwright context runs with
|
||||
* `serviceWorkers: "block"` (see `playwright.config.ts`), so the worker's
|
||||
* `healthlog-data-*` cache — the other layer that survives a reload — is not
|
||||
* exercised here at all. The switch wipes it through the same helper the
|
||||
* IndexedDB snapshot goes through, and that helper is covered in
|
||||
* `src/lib/pwa/__tests__/query-persister-shared-record.test.ts`.
|
||||
* - **The owner's data appearing.** No route declares the delegable mode yet,
|
||||
* so every read inside a shared record is refused by design. That is the
|
||||
* current, correct state of the tree; the pages a delegate can open show
|
||||
* their refusal state and, crucially, still show nothing of the delegate's
|
||||
* own. The positive half of that assertion belongs with the route migration.
|
||||
*/
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER,
|
||||
E2E_USER,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
|
||||
const DELEGATE_MARKER = String(E2E_DELEGATE_MARKER_KG);
|
||||
|
||||
/**
|
||||
* Latch the first appearance of a string anywhere in the document, from before
|
||||
* the first script of every page load.
|
||||
*
|
||||
* A MutationObserver rather than a poll: a poll samples, and the frame this is
|
||||
* looking for can be one paint long. `characterData` is on because a hydration
|
||||
* pass can fill an existing text node in place rather than inserting one.
|
||||
*/
|
||||
async function watchForText(page: Page, needle: string): Promise<void> {
|
||||
await page.addInitScript((text: string) => {
|
||||
const w = window as unknown as { __sawText?: boolean };
|
||||
w.__sawText = false;
|
||||
const seen = () => document.body?.innerText?.includes(text) ?? false;
|
||||
const check = () => {
|
||||
if (seen()) w.__sawText = true;
|
||||
};
|
||||
const start = () => {
|
||||
check();
|
||||
new MutationObserver(check).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}, needle);
|
||||
}
|
||||
|
||||
async function sawText(page: Page): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __sawText?: boolean }).__sawText === true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the user menu and the switcher inside it. */
|
||||
async function openSwitcher(page: Page): Promise<void> {
|
||||
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("account sharing", () => {
|
||||
// One journey, in order: each step is the precondition of the next, so
|
||||
// splitting them into independent tests would mean re-running the invitation
|
||||
// three times against a rate-limited endpoint.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
let ownerContext: BrowserContext;
|
||||
let ownerPage: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ownerContext = await browser.newContext({
|
||||
storageState: OWNER_STORAGE_STATE_PATH,
|
||||
});
|
||||
ownerPage = await ownerContext.newPage();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await ownerContext.close();
|
||||
});
|
||||
|
||||
test("the owner invites, and the invitation confers nothing yet", async () => {
|
||||
await ownerPage.goto("/settings/access");
|
||||
|
||||
await ownerPage
|
||||
.locator('[data-slot="grant-invite-identifier"]')
|
||||
.fill(E2E_USER.username);
|
||||
await ownerPage.locator('[data-slot="grant-invite-submit"]').click();
|
||||
|
||||
const row = ownerPage
|
||||
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
||||
.first();
|
||||
await expect(row).toBeVisible();
|
||||
// Pending, not active. The state is the server's answer, rendered.
|
||||
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
|
||||
});
|
||||
|
||||
test("the delegate is offered nothing until they accept", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/access");
|
||||
|
||||
const row = page
|
||||
.locator('[data-slot="grants-received-list"] [data-slot="grant-row"]')
|
||||
.first();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
|
||||
|
||||
// No switcher yet: `accountAccess.canSwitch` is false while the only
|
||||
// grant is pending, and the menu binds that boolean.
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-trigger"]'),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.locator('[data-slot="grant-accept"]').click();
|
||||
await expect(row).toHaveAttribute("data-grant-state", "ACTIVE");
|
||||
});
|
||||
|
||||
test("the switcher appears once the grant is live", async ({ page }) => {
|
||||
await page.goto("/settings/access");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator('[data-slot="account-switcher-entry"]');
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toContainText(E2E_OWNER.username);
|
||||
// The way home is in the same menu as the way in.
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-own"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("switching shows the banner and never the delegate's own data", async ({
|
||||
page,
|
||||
}) => {
|
||||
await watchForText(page, DELEGATE_MARKER);
|
||||
|
||||
// Positive control. If the observer cannot see the marker on the page
|
||||
// that definitely shows it, its silence later means nothing.
|
||||
await page.goto("/measurements");
|
||||
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
||||
expect(await sawText(page)).toBe(true);
|
||||
|
||||
// Let the offline persister flush the delegate's own dashboard to
|
||||
// IndexedDB (1 s debounce). That snapshot is the stale-paint source this
|
||||
// whole assertion is aimed at — without it the check would pass against a
|
||||
// disk that was empty anyway.
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await openSwitcher(page);
|
||||
await page.locator('[data-slot="account-switcher-entry"]').click();
|
||||
|
||||
// Wait for CONTENT, and for content the assertion is not about: the
|
||||
// banner naming the owner. A skeleton does not satisfy it, and it says
|
||||
// nothing about the marker either way.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(E2E_OWNER.username);
|
||||
|
||||
// The latch was reset by the reload's init script, so this covers the
|
||||
// whole life of the switched document.
|
||||
expect(await sawText(page)).toBe(false);
|
||||
|
||||
// And it stays false across a navigation inside the record.
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
expect(await sawText(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("the nav offers only what sharing covers", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The record itself is still there…
|
||||
await expect(page.locator('a[href="/measurements"]').first()).toBeVisible();
|
||||
// …and everything that configures the account around it is not.
|
||||
await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/coach"]')).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/insights"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a surface sharing does not cover says so", async ({ page }) => {
|
||||
// Reached by bookmark: the nav entry is gone, the URL still resolves.
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable-leave"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("revoking from the owner's browser drops the delegate out", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The owner ends it from their own session, in their own context.
|
||||
await ownerPage.goto("/settings/access");
|
||||
await ownerPage.locator('[data-slot="grant-revoke"]').first().click();
|
||||
await ownerPage
|
||||
.getByRole("button", { name: "End access", exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
ownerPage
|
||||
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
||||
.first(),
|
||||
).toHaveAttribute("data-grant-state", "REVOKED");
|
||||
|
||||
// The delegate's next navigation lands back in their own account rather
|
||||
// than on a wall of refusals: the revoke cleared their acting session in
|
||||
// the same transaction that stamped the row.
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
// Their own record, back — which is also the proof that the page did not
|
||||
// simply fail to load.
|
||||
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("the ended grant stays on the record as history", async () => {
|
||||
await ownerPage.goto("/settings/access");
|
||||
const row = ownerPage
|
||||
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
|
||||
.first();
|
||||
// Revocation never deletes. "Who had access, from when to when, and who
|
||||
// ended it" is the question the panel exists to answer.
|
||||
await expect(row).toHaveAttribute("data-grant-state", "REVOKED");
|
||||
await expect(row).toContainText("You ended it");
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,39 @@ export const E2E_USER = {
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.36.0 — the SECOND account, so account sharing has two sides to test.
|
||||
*
|
||||
* Sharing is the one feature in the product that cannot be exercised by one
|
||||
* person: somebody has to own a record and somebody else has to be given it.
|
||||
* This account owns the record; `E2E_USER` is the delegate throughout the
|
||||
* sharing spec.
|
||||
*/
|
||||
export const E2E_OWNER = {
|
||||
email: "e2e-owner@healthlog.test",
|
||||
username: "e2e-owner",
|
||||
password: "Qv7bR2n!Ks9wLd4T",
|
||||
role: "USER",
|
||||
} 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
|
||||
* writes.
|
||||
*
|
||||
* The delegate's number is the whole point of the stale-paint check: after
|
||||
* switching into the owner's record it must appear NOWHERE, at no moment. A
|
||||
* spec that only waited for the owner's data to arrive would prove nothing
|
||||
* about what was on screen in between.
|
||||
*/
|
||||
export const E2E_DELEGATE_MARKER_KG = 77.7;
|
||||
export const E2E_OWNER_MARKER_KG = 63.3;
|
||||
|
||||
export const OWNER_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateOwner.json",
|
||||
);
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, {
|
||||
memoryCost: 19456,
|
||||
@@ -179,6 +212,77 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
[cuid(), now, E2E_USER.username],
|
||||
);
|
||||
|
||||
// ── v1.36.0 — the sharing fixture ────────────────────────────────────
|
||||
//
|
||||
// A second account, one distinctive weight in each record, and a clean
|
||||
// grant table. The reset matters as much as the seed: grants and switch
|
||||
// stamps outlive a run, and a second run starting with the delegate
|
||||
// already inside the owner's record would skip the invitation half of
|
||||
// the journey while still going green.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role,
|
||||
created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
|
||||
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`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_OWNER.username,
|
||||
E2E_OWNER.email,
|
||||
await hashPassword(E2E_OWNER.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// Every grant between the pair, and any switch stamp either of them is
|
||||
// carrying. Deleted rather than revoked: this is fixture hygiene, not a
|
||||
// lifecycle transition, and a revoked row would still show in the panel.
|
||||
await pool.query(
|
||||
`DELETE FROM account_grants
|
||||
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
|
||||
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||||
[[E2E_USER.username, E2E_OWNER.username]],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE sessions SET acting_as_user_id = NULL
|
||||
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||||
[[E2E_USER.username, E2E_OWNER.username]],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key LIKE 'sharing:%'`,
|
||||
);
|
||||
|
||||
// One marker weight per record. Re-seeded by delete-then-insert so a
|
||||
// re-run cannot accumulate duplicates and so an edited constant takes
|
||||
// effect rather than sitting beside the old value.
|
||||
for (const [username, kg] of [
|
||||
[E2E_USER.username, E2E_DELEGATE_MARKER_KG],
|
||||
[E2E_OWNER.username, E2E_OWNER_MARKER_KG],
|
||||
] as const) {
|
||||
await pool.query(
|
||||
`DELETE FROM measurements
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)
|
||||
AND external_id = 'e2e-sharing-marker'`,
|
||||
[username],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at,
|
||||
external_id, created_at, updated_at)
|
||||
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
|
||||
'e2e-sharing-marker', $3, $3
|
||||
FROM users u WHERE u.username = $4`,
|
||||
[cuid(), kg, now, username],
|
||||
);
|
||||
}
|
||||
|
||||
// Console (instead of structured logging) is intentional here —
|
||||
// global-setup runs outside the app's logging context, and the
|
||||
// line is useful when debugging a CI failure where the seed didn't
|
||||
@@ -246,4 +350,40 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
console.log(
|
||||
`[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`,
|
||||
);
|
||||
|
||||
// v1.36.0 — the owner's cookie jar, captured the same way and for the same
|
||||
// reason: the login endpoint is IP-rate-limited and the sharing spec needs
|
||||
// both sides of a grant signed in at once.
|
||||
const ownerCtx = await playwrightRequest.newContext({ baseURL });
|
||||
const ownerRes = await ownerCtx.post("/api/auth/login", {
|
||||
data: { email: E2E_OWNER.username, password: E2E_OWNER.password },
|
||||
});
|
||||
if (ownerRes.status() !== 200) {
|
||||
const body = await ownerRes.text();
|
||||
throw new Error(
|
||||
`[e2e/global-setup] owner login failed: HTTP ${ownerRes.status()} — ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
const ownerState = await ownerCtx.storageState();
|
||||
ownerState.cookies = ownerState.cookies.filter(
|
||||
(c) => c.name === "healthlog_session" || c.name === "hl_onboarding",
|
||||
);
|
||||
ownerState.cookies.push({
|
||||
name: "healthlog-locale",
|
||||
value: "en",
|
||||
domain: base.hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
await writeFile(
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
JSON.stringify(ownerState, null, 2),
|
||||
);
|
||||
await ownerCtx.dispose();
|
||||
console.log(
|
||||
`[e2e/global-setup] owner auth state captured to ${OWNER_STORAGE_STATE_PATH}`,
|
||||
);
|
||||
}
|
||||
@@ -6,4 +6,11 @@
|
||||
* specific to the spec-side (e.g. multi-user scenarios that need
|
||||
* fresh logins) have a natural home.
|
||||
*/
|
||||
export { E2E_USER, STORAGE_STATE_PATH } from "./global-setup";
|
||||
export {
|
||||
E2E_USER,
|
||||
E2E_OWNER,
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER_MARKER_KG,
|
||||
STORAGE_STATE_PATH,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
} from "./global-setup";
|
||||
Reference in new issue
Block a user