Files
HealthLog/e2e/today-overview.spec.ts
Marc-André Bombeck d3c672cc3f feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:

- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
  (innerKeyIds) or an off-host object inside the bucket's retention
  (OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
  named by the id their objects record;
- API_TOKEN_HMAC_KEY.

Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.

Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
2026-10-25 13:32:29 +01:00

592 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import type { DailyDigest } from "@/lib/daily/digest";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
/**
* The Today overview on the dashboard hero, at a desktop and a phone width.
*
* What it proves is geometry, because that is what the unit suites cannot
* see: the facts sit beside the ring on a wide screen; on a phone the ring is
* a compact dial at the top right beside the lead and the facts run the full
* card width under both; a phone shows four facts and a wide screen five,
* nothing pushes the page sideways, the hero keeps the width of the cards
* around it, and the ring keeps its size per breakpoint. Each state is also
* captured as an element screenshot, in both themes, under
* `test-results/today-overview/` for a visual check.
*
* The digests are what the server would publish for each state. They carry
* the pre-overview fields too (`briefingLead`, `line`), with the values the
* old server sent, so the same fixtures render the previous hero on an older
* build for a before/after comparison.
*
* Assertions anchor on `data-slot` / `data-kind`, never on visible text.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const SHOTS = join(process.cwd(), "test-results", "today-overview");
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
{ name: "narrow", width: 360, height: 780 },
] as const;
const THEMES = ["dark", "light"] as const;
/** The dial's edge per breakpoint: compact on a phone, md from 768 px. */
const RING_PX = { phone: 80, wide: 168 } as const;
/** Below this the lead beside the ring would read as a column of words. */
const MIN_LEAD_WIDTH = 180;
const BASE: DailyDigest = {
generatedAt: new Date().toISOString(),
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
value: 94,
band: "green",
delta: null,
deltaReason: "first_eligibility_window",
steadyWeeks: 4,
},
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
today: [],
restMode: null,
line: "Your health score today is 94.",
worthALook: [],
justIn: null,
reactionLine: null,
};
const FACTS: DailyDigest["today"] = [
{
kind: "medications",
label: "Medications",
value: "1 of 3 taken",
href: "/medications",
moduleKey: "medications",
},
{
kind: "appointment",
label: "Appointment",
value: "Tomorrow 09:30, Routine visit",
href: "/checkups",
},
{
kind: "sleep",
label: "Last night",
value: "7h 20m, close to your usual",
href: "/insights/sleep",
moduleKey: "sleep",
},
{
kind: "vitals",
label: "Vitals",
value: "6 checked, all in your range",
href: "/insights",
},
{
kind: "cycle",
label: "Cycle",
value: "Follicular, day 12",
href: "/cycle",
moduleKey: "cycle",
},
];
const STATES: Record<string, DailyDigest> = {
// AI on. The old server led with the briefing's first sentence, which was
// the greeting.
"ai-on": {
...BASE,
briefingLead: "Good morning.",
line: "Good morning.",
topSignal: {
sourceMetric: "bp",
tone: "good",
headline: "Blood pressure is sitting in the optimal band",
nudge: "",
delta: "↓ ~10 mmHg systolic vs the start of the window",
},
lead: {
text: "Your latest blood pressure is sitting in the optimal band.",
source: "briefing",
},
// The lead already talks about blood pressure, so the server keeps only
// the delta under it.
signalLine: {
headline: null,
delta: "↓ ~10 mmHg systolic vs the start of the window",
},
today: FACTS,
worthALook: [
{
kind: "dose_window",
title: "Medication due",
body: "Ramipril is due soon.",
status: "info",
actions: [
{
labelKey: "daily.action.logDose",
intent: "dose.log",
href: "/medications",
},
],
moduleKey: "medications",
},
],
},
// AI off. The old server had only the score sentence, which the hero then
// removed again, leaving the ring and "Nothing needs your attention".
"ai-off": {
...BASE,
ai: {
...DIGEST_AI_AVAILABLE,
briefing: {
available: false,
reason: "no_provider",
onDeviceAllowed: false,
},
},
lead: {
text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
source: "signal",
},
today: [FACTS[0], FACTS[2], FACTS[4]],
},
// AI on with a long lead and a long sleep value: the phone case the ring
// used to fall under the facts for.
"ai-on-long": {
...BASE,
score: {
value: 63,
band: "yellow",
delta: -6,
deltaReason: null,
steadyWeeks: null,
},
lead: {
text: "Your pulse sits well above its usual level this evening, after a shorter night than you normally get; a quieter evening would suit it.",
source: "briefing",
},
signalLine: { headline: null, delta: "+34 bpm vs your 30-day average" },
today: [
FACTS[0],
{
kind: "sleep",
label: "Last night",
value: "5h 45m, 42m less than usual",
href: "/insights/sleep",
moduleKey: "sleep",
},
{
kind: "vitals",
label: "Vitals",
value: "Pulse above your range",
href: "/insights",
},
],
},
// A quiet day: one calm sentence and no facts beyond it.
quiet: {
...BASE,
lead: {
text: "All 6 of your latest vitals sit inside their usual range.",
source: "signal",
},
},
};
async function openDashboard(page: Page, digest: DailyDigest) {
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: digest, error: null }),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-slot="dashboard-tile-strip"]')).toBeVisible({
timeout: 15_000,
});
return hero;
}
test.describe("Today overview geometry", () => {
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
});
for (const viewport of VIEWPORTS) {
for (const theme of THEMES) {
for (const [state, digest] of Object.entries(STATES)) {
test(`${state} at ${viewport.width}px, ${theme}`, async ({ page }) => {
// The app paints from its own stored preference, not the media
// query, so both are set.
await page.emulateMedia({
colorScheme: theme,
reducedMotion: "reduce",
});
await page.addInitScript((value: string) => {
window.localStorage.setItem("healthlog-theme", value);
}, theme);
await page.setViewportSize(viewport);
const hero = await openDashboard(page, digest);
const phone = viewport.width < 768;
// Late banners (the recovery kit nudge) insert above the hero; let
// the page settle so the capture shows one stable layout.
await page.waitForLoadState("networkidle");
mkdirSync(SHOTS, { recursive: true });
await hero.screenshot({
path: join(SHOTS, `${viewport.name}-${theme}-${state}.png`),
});
// The viewport as a reader sees it, bottom navigation included.
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: join(
SHOTS,
`${viewport.name}-${theme}-${state}-viewport.png`,
),
});
// Nothing pushes the page sideways.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// The hero spans the same column as the tile strip below it.
const heroBox = (await hero.boundingBox())!;
const stripBox = (await page
.locator('[data-slot="dashboard-tile-strip"]')
.boundingBox())!;
expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1);
expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(
1,
);
// The ring keeps its size: a compact dial on a phone, the same
// 168 px dial as before from md up.
const ring = hero.locator('[data-slot="score-ring"]').first();
const ringBox = (await ring.boundingBox())!;
const edge = phone ? RING_PX.phone : RING_PX.wide;
expect(Math.round(ringBox.width)).toBe(edge);
expect(Math.round(ringBox.height)).toBe(edge);
const lead = hero.locator('[data-slot="today-hero-lead"]');
await expect(lead).toBeVisible();
await expect(lead).toHaveAttribute(
"data-source",
digest.lead!.source,
);
const leadBox = (await lead.boundingBox())!;
// The ring sits at the top right, beside the lead, and the lead
// keeps a readable width next to it.
expect(ringBox.x).toBeGreaterThanOrEqual(leadBox.x + leadBox.width);
expect(ringBox.y).toBeLessThan(leadBox.y + leadBox.height);
expect(Math.abs(ringBox.y - leadBox.y)).toBeLessThanOrEqual(8);
expect(leadBox.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH);
// Symmetric insets: the ring's right edge mirrors the lead's left.
const leftInset = leadBox.x - heroBox.x;
const rightInset =
heroBox.x + heroBox.width - (ringBox.x + ringBox.width);
expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1);
const facts = hero.locator('[data-slot="today-hero-fact"]');
const expected = digest.today.length;
await expect(facts).toHaveCount(expected);
const visible = await facts.evaluateAll(
(items) =>
items.filter((el) => (el as HTMLElement).offsetParent !== null)
.length,
);
expect(visible).toBe(phone ? Math.min(expected, 4) : expected);
// The all-clear sentence never sits under a lead or facts.
await expect(
hero.locator('[data-slot="today-hero-all-clear"]'),
).toHaveCount(0);
if (expected === 0) {
await expect(
hero.locator('[data-slot="today-hero-today"]'),
).toHaveCount(0);
// A quiet day keeps the full composition: lead beside the ring.
await expect(hero).toHaveAttribute("data-layout", "narrative");
return;
}
const block = hero.locator('[data-slot="today-hero-today"]');
const blockBox = (await block.boundingBox())!;
// The facts follow the lead.
expect(blockBox.y).toBeGreaterThanOrEqual(
leadBox.y + leadBox.height - 1,
);
if (!phone) {
// Beside the ring on a wide screen, never under it.
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(
ringBox.x + 1,
);
expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height);
} else {
// Under the dial on a phone, across the full card width.
expect(blockBox.y).toBeGreaterThanOrEqual(
ringBox.y + ringBox.height - 1,
);
expect(
Math.abs(
blockBox.x + blockBox.width - (ringBox.x + ringBox.width),
),
).toBeLessThanOrEqual(1);
// Each tappable fact keeps the 44 px floor, label over value,
// both on the reading edge.
const rows = await facts.evaluateAll((items) =>
items
.filter((el) => (el as HTMLElement).offsetParent !== null)
.map((el) => {
const link = el.querySelector("a")!;
const [label, value] = Array.from(link.children).map((c) =>
c.getBoundingClientRect(),
);
return {
height: link.getBoundingClientRect().height,
stacked: value.top >= label.bottom - 1,
sameEdge: Math.abs(value.left - label.left) <= 1,
};
}),
);
for (const row of rows) {
expect(row.height).toBeGreaterThanOrEqual(44);
expect(row.stacked).toBe(true);
expect(row.sameEdge).toBe(true);
}
}
// No fact line overflows its own box.
const clipped = await facts.evaluateAll(
(items) =>
items.filter((el) => {
const link = el.querySelector("a");
return link ? link.scrollWidth > link.clientWidth + 1 : false;
}).length,
);
expect(clipped).toBe(0);
});
}
}
}
test("the hero width does not move when the overview arrives", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
let calls = 0;
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
await page.route(/\/api\/daily\/digest(\?|$)/, async (route) => {
calls += 1;
// First paint: a quiet digest. Every later read carries the overview.
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: calls > 1 ? STATES["ai-on"] : STATES.quiet,
error: null,
}),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 15_000 });
const before = (await hero.boundingBox())!;
// The tab becomes visible again: the signal TanStack Query's focus
// manager refetches on, as `today-just-in.spec.ts` uses.
await page.evaluate(() =>
window.dispatchEvent(new Event("visibilitychange")),
);
await expect(hero.locator('[data-slot="today-hero-today"]')).toBeVisible({
timeout: 15_000,
});
const after = (await hero.boundingBox())!;
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
});
});
/**
* The phone in German, where the score's meta lines are longest. In the
* dial's 80 px column "−6 vs. dein Mittel" and the basis sentence broke into
* three to five rows each; a short lead then left a gap above the facts as
* tall as that column. The lines now run on their own row under the dial.
*/
const GERMAN_SCORE: DailyDigest["score"] = {
value: 63,
band: "yellow",
delta: -6,
deltaReason: null,
steadyWeeks: null,
scoreBasis: {
domains: 2,
recommended: 3,
tier: "partial",
physiological: true,
},
};
const GERMAN_FACTS: DailyDigest["today"] = [
{
kind: "medications",
label: "Medikamente",
value: "1 von 2 genommen",
href: "/medications",
moduleKey: "medications",
},
{
kind: "sleep",
label: "Letzte Nacht",
value: "5 Std. 45 Min., 42 Min. weniger als sonst",
href: "/insights/sleep",
moduleKey: "sleep",
},
{
kind: "vitals",
label: "Vitalwerte",
value: "Puls über deinem Bereich",
href: "/insights",
},
];
const GERMAN_STATES: Record<string, DailyDigest> = {
"de-short": {
...BASE,
score: GERMAN_SCORE,
lead: { text: "Dein Puls liegt heute erhöht.", source: "signal" },
today: GERMAN_FACTS,
},
"de-long": {
...BASE,
score: GERMAN_SCORE,
lead: {
text: "Dein Puls liegt heute Abend deutlich über seinem üblichen Niveau, nach einer kürzeren Nacht als sonst; ein ruhiger Abend täte ihm gut.",
source: "briefing",
},
signalLine: { headline: null, delta: "+34 bpm vs. dein 30-Tage-Mittel" },
today: GERMAN_FACTS,
},
};
test.describe("Today overview on a German phone", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
for (const viewport of VIEWPORTS.filter((v) => v.width < 768)) {
for (const [state, digest] of Object.entries(GERMAN_STATES)) {
test(`${state} at ${viewport.width}px`, async ({ page }) => {
await page.emulateMedia({
colorScheme: "dark",
reducedMotion: "reduce",
});
await page.addInitScript(() => {
window.localStorage.setItem("healthlog-theme", "dark");
});
await page.setViewportSize(viewport);
const hero = await openDashboard(page, digest);
await page.waitForLoadState("networkidle");
mkdirSync(SHOTS, { recursive: true });
await hero.screenshot({
path: join(SHOTS, `${viewport.name}-dark-${state}.png`),
});
const box = async (slot: string) =>
(await hero.locator(`[data-slot="${slot}"]`).first().boundingBox())!;
const ring = await box("score-ring");
const lead = await box("today-hero-lead");
const meta = await box("today-hero-score-meta");
const block = await box("today-hero-today");
// The dial keeps its size and its place beside the lead.
expect(Math.round(ring.width)).toBe(RING_PX.phone);
expect(lead.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH);
// Each meta line reads on one line, flush with the dial's edge.
const lines = await hero
.locator('[data-slot="today-hero-score-meta"] > span')
.evaluateAll((spans) =>
spans.map((span) => {
const rect = span.getBoundingClientRect();
const lineHeight = parseFloat(getComputedStyle(span).lineHeight);
return {
rows: Math.round(rect.height / lineHeight),
right: rect.right,
};
}),
);
expect(lines.length).toBe(2);
for (const line of lines) {
expect(line.rows).toBe(1);
expect(
Math.abs(line.right - (ring.x + ring.width)),
).toBeLessThanOrEqual(1);
}
// The meta row follows the read row at once: right under the dial
// beside a short lead, right under a long lead's last line.
// The lead's cell ends with its signal line when there is one.
const signalLine = hero.locator('[data-slot="today-hero-signal"]');
const signal =
(await signalLine.count()) > 0
? await signalLine.boundingBox()
: null;
const leadBottom = signal
? signal.y + signal.height
: lead.y + lead.height;
const readBottom = Math.max(ring.y + ring.height, leadBottom);
expect(meta.y - readBottom).toBeGreaterThanOrEqual(0);
expect(meta.y - readBottom).toBeLessThanOrEqual(6);
// No gap above the facts beyond one section step: they start one
// row gap (16 px) under whatever ends lowest, the lead or the meta.
const above = Math.max(leadBottom, meta.y + meta.height);
expect(block.y - above).toBeGreaterThanOrEqual(15);
expect(block.y - above).toBeLessThanOrEqual(17);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
}
}
});