mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
198 lines
6.0 KiB
TypeScript
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 });
|
|
});
|
|
});
|