Files
HealthLog/e2e/ai-optional-account.spec.ts
Marc-André Bombeck e2b7e7350e fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
2026-10-16 17:11:04 +02:00

131 lines
4.9 KiB
TypeScript

/**
* What happens to stored AI text and stored Coach rows, against the real
* server, on an account of its own (`E2E_AI_OPTIONAL`).
*
* J5 — consent withdrawn. The account has a local model (an address nothing
* listens on: presence is all the capability reads, nothing here calls
* it), a daily briefing a model wrote earlier, and an AI consent. The
* briefing is on the overview; the consent is withdrawn in Settings, AI
* through the real control; the briefing is gone, because the server
* deleted the notes written under that consent.
* J4 — Coach hidden. With Hide Coach on, Settings, Coach is out of reach, so
* Settings, AI lists what the Coach stored; one stored conversation is
* deleted from there and is gone from the database.
*
* Both journeys move the same account's state, so they run serially, in one
* project (see `playwright.config.ts`).
*/
import type { Page } from "@playwright/test";
import { AI_OPTIONAL_STORAGE_STATE_PATH } from "./setup/global-setup";
import {
coachConversationCount,
resetAiOptionalAccount,
seedHiddenCoachWithConversation,
seedStoredBriefing,
} from "./setup/ai-optional-fixture";
import { expect, test } from "./setup/test";
import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
test.beforeEach(async () => {
await resetAiOptionalAccount();
});
test.afterAll(async () => {
await resetAiOptionalAccount();
});
/**
* The overview with every section mounted. Sections past the first three
* render as an empty sentinel until they near the viewport, so a count of
* zero taken before they mount would pass whatever they paint.
*/
async function openOverview(page: Page): Promise<void> {
await page.goto("/insights");
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
await revealDeferredSectionsSettled(page);
}
test("J5: withdrawing AI consent deletes the notes written under it", async ({
page,
}) => {
test.setTimeout(90_000);
const marker = `Stored briefing ${Date.now()}`;
await seedStoredBriefing(marker);
// Positive control: the stored briefing is what the overview shows.
await openOverview(page);
await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(1);
await expect(page.getByText(marker).first()).toBeVisible();
// Withdraw through the real control. The confirm names what goes and what
// stays before anything happens.
await page.goto("/settings/ai");
await page.locator('[data-slot="ai-consent-withdraw"]').click();
const withdrawn = page.waitForResponse(
(res) =>
res.url().includes("/api/consent/ai/latest") &&
res.request().method() === "DELETE",
);
await page.locator('[data-slot="ai-consent-withdraw-confirm"]').click();
expect((await withdrawn).status()).toBe(200);
// On a local model nothing needs the consent, so once withdrawn there is
// nothing to withdraw and nothing to ask.
await expect(page.locator('[data-slot="ai-consent-withdraw"]')).toHaveCount(
0,
);
await openOverview(page);
await expect(page.getByText(marker)).toHaveCount(0);
});
test("J4: the Coach memory stays readable and deletable with the Coach hidden", async ({
page,
}) => {
test.setTimeout(90_000);
const title = `Stored conversation ${Date.now()}`;
await seedHiddenCoachWithConversation(title);
// Hide Coach takes the Coach page, its launcher and every per-card
// hand-off away...
await openOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0);
await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0);
// ...and Settings, AI lists what the Coach stored.
await page.goto("/settings/ai");
const card = page.locator(
'[data-testid="settings-coach-conversations-card"]',
);
await expect(card).toBeVisible({ timeout: 20_000 });
const row = card
.locator('[data-testid="settings-coach-conversation"]')
.filter({ hasText: title });
await expect(row).toHaveCount(1);
// Readable: the row opens its transcript in place.
await row.locator('[data-slot="settings-coach-conversation-open"]').click();
await expect(
row.locator('[data-slot="settings-coach-conversation-open"]'),
).toHaveAttribute("aria-expanded", "true");
// Deletable: the row goes at once, and the delete reaches the server once
// the undo window closes.
const deleted = page.waitForResponse(
(res) =>
/\/api\/insights\/chat\/[^/]+$/.test(new URL(res.url()).pathname) &&
res.request().method() === "DELETE",
{ timeout: 30_000 },
);
await row.locator('[data-slot="settings-coach-conversation-delete"]').click();
await expect(row).toHaveCount(0);
expect((await deleted).ok()).toBe(true);
expect(await coachConversationCount()).toBe(0);
});