Files
HealthLog/e2e/central-codex.spec.ts
T
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00

198 lines
6.0 KiB
TypeScript

import { expect, test } from "@playwright/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,
}),
}),
);
await page.route("**/api/consent/ai/web", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { ok: true }, 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 });
});
});