mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
190 lines
5.8 KiB
TypeScript
190 lines
5.8 KiB
TypeScript
import { expect, test } from "./setup/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* Operator-shared central Codex (ChatGPT subscription) — two mocked flows:
|
|
*
|
|
* A. The per-user opt-in switch on /settings/ai, shown only when the operator
|
|
* has connected the shared account (`centralCodexAvailable: true`). Toggle
|
|
* on → honesty confirm → PATCH /api/auth/me/use-central-codex.
|
|
* B. The admin connect surface on /admin/coach: device-code panel →
|
|
* connected state, driven by the three admin endpoints.
|
|
*
|
|
* The real OAuth handshake bounces through chatgpt.com, which CI never hits;
|
|
* every relevant endpoint is mocked so the assertions focus on the UI state.
|
|
*/
|
|
test.describe("central codex opt-in switch (mocked)", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test("switch shows when available and writes the opt-in on confirm", async ({
|
|
page,
|
|
}) => {
|
|
let useCentralCodex = false;
|
|
|
|
await page.route("**/api/insights/settings", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
codexStatus: "disconnected",
|
|
codexConnectedAt: null,
|
|
hasAdminKey: false,
|
|
codexOauthConfigured: true,
|
|
centralCodexAvailable: true,
|
|
useCentralCodex,
|
|
privacyMode: "aggregated",
|
|
lastInsightAt: null,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/user/ai-provider", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
provider: null,
|
|
model: null,
|
|
baseUrl: null,
|
|
hasAnthropicKey: false,
|
|
anthropicKeyPreview: null,
|
|
hasLocalKey: false,
|
|
hasOpenaiKey: false,
|
|
openaiKeyPreview: null,
|
|
responseTimeoutSeconds: null,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/insights/provider-chain", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
activeProvider: "codex",
|
|
cachedActiveProvider: null,
|
|
configuredChain: [{ providerType: "codex", available: true }],
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { documentsAutoAiRead: false },
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// The opt-in write — flips the mocked state so a re-read reflects it.
|
|
await page.route("**/api/auth/me/use-central-codex", (route) => {
|
|
useCentralCodex = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { useCentralCodex: true },
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
|
|
|
|
const toggle = page.getByTestId("use-central-codex-enable");
|
|
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Off → on reveals the honesty confirm; the write only happens on confirm.
|
|
await toggle.click();
|
|
const confirm = page.locator('[data-slot="central-codex-confirm-cta"]');
|
|
await expect(confirm).toBeVisible();
|
|
|
|
const wrote = page.waitForRequest(
|
|
(req) =>
|
|
req.url().includes("/api/auth/me/use-central-codex") &&
|
|
req.method() === "PATCH",
|
|
);
|
|
await confirm.click();
|
|
await wrote;
|
|
});
|
|
});
|
|
|
|
test.describe("admin central codex connect (mocked)", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test("connect button → device panel → connected badge", async ({ page }) => {
|
|
let connected = false;
|
|
|
|
await page.route("**/api/admin/central-codex", (route) => {
|
|
if (route.request().method() !== "GET") return route.continue();
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
status: connected ? "connected" : "disconnected",
|
|
connectedAt: connected ? new Date().toISOString() : null,
|
|
},
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/admin/central-codex/device-start", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
userCode: "WXYZ-7890",
|
|
verificationUrl: "https://chatgpt.com/codex/device",
|
|
intervalSeconds: 1,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
let pollCount = 0;
|
|
await page.route("**/api/admin/central-codex/device-poll", (route) => {
|
|
pollCount += 1;
|
|
if (pollCount >= 2) connected = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { status: pollCount >= 2 ? "connected" : "pending" },
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.goto("/admin/coach", { waitUntil: "domcontentloaded" });
|
|
|
|
const connect = page.locator('[data-slot="admin-central-codex-connect"]');
|
|
await expect(connect).toBeVisible({ timeout: 10_000 });
|
|
await connect.click();
|
|
|
|
// Device-code panel surfaces the user code.
|
|
await expect(page.getByText("WXYZ-7890")).toBeVisible();
|
|
|
|
// After the mocked poll flips to connected, the disconnect action shows.
|
|
await expect(
|
|
page.locator('[data-slot="admin-central-codex-disconnect"]'),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
});
|
|
});
|