Files
HealthLog/src/__tests__/ai-input-invalidation-guard.test.ts
T
Marc-André Bombeck 676bb2eff7 fix(ai): refresh the account's AI answer after every change to its inputs
Every AI surface reads its capability from the ai block on
/api/auth/me, but setting up or removing a provider, signing in to or out
of Codex, saving the fallback chain, the central-Codex opt-in, the
local-OCR switch, a minted consent receipt, the admin's server key and
central Codex, and a guardian's save inside a managed record evicted only
their own reads. The surfaces stayed on the old answer until a reload.

All of these writes, and the ones that already refreshed the payload
(consent, modules, Hide Coach, the operator's switches), now evict one
bundle, aiInputDependentKeys. A structural test holds every client write
to those endpoints to it.
2026-09-24 12:11:14 +02:00

121 lines
4.3 KiB
TypeScript

/**
* Structural guard: every client write that changes an input of the `ai`
* block on `GET /api/auth/me` evicts it through `aiInputDependentKeys`.
*
* Every AI surface on the web reads its capability from that block. Before
* v1.39 shipped, the provider forms, the Codex sign-in, the fallback chain and
* the central-Codex switch evicted only the provider and insights reads, so a
* freshly configured provider left every AI surface on "not set up" until a
* reload. The fix routes all of them through one bundle; this guard keeps a
* new write site from re-deciding the keys by hand.
*
* The matcher finds write calls (`apiPost/apiPut/apiPatch/apiDelete`, and
* `apiFetch`/`apiFetchRaw` with a write method) whose literal path names one
* of the endpoints below, and asserts the file calls
* `invalidateKeys(<client>, aiInputDependentKeys)`. A tripwire, not a proof:
* it holds the file, not each mutation in it (one mutation can branch over
* two endpoints and evict once), and a path built at runtime would slip the
* matcher, which is why the matched total carries a floor and an empty sweep
* fails.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { aiInputDependentKeys, queryKeys } from "@/lib/query-keys";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* The endpoints whose writes change an input of the capability answer.
* Starting a device sign-in changes nothing until the poll reports success,
* so the `device-start` legs are not listed.
*/
const AI_INPUT_ENDPOINTS = [
"/api/user/ai-provider",
"/api/insights/provider-chain",
"/api/auth/codex/device-poll",
"/api/auth/codex/disconnect",
"/api/auth/me/use-central-codex",
"/api/auth/me/labs-local-ocr",
"/api/consent/ai",
"/api/auth/me/modules",
"/api/auth/me/disable-coach",
"/api/admin/ai-settings",
"/api/admin/settings/assistant-flags",
"/api/admin/settings/module-availability",
"/api/admin/central-codex/device-poll",
"/api/admin/central-codex",
// A managed record's modules / coach / profile families, written by the
// guardian from inside that record.
"/api/record-settings",
] as const;
const HELPER_WRITE =
/\bapi(?:Post|Put|Patch|Delete)\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)/g;
const RAW_WRITE =
/\bapiFetch(?:Raw)?\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)[^"'`]*["'`]\s*,\s*\{[^}]*?\bmethod:\s*["'](?:POST|PUT|PATCH|DELETE)["']/g;
const BUNDLE_CALL =
/\binvalidateKeys\(\s*[A-Za-z_]\w*\s*,\s*aiInputDependentKeys\b/g;
function isAiInputEndpoint(path: string): boolean {
if (path.endsWith("/device-start")) return false;
return AI_INPUT_ENDPOINTS.some((e) => path === e || path.startsWith(`${e}/`));
}
function inScope(rel: string): boolean {
if (rel.includes("__tests__") || /\.test\.tsx?$/.test(rel)) return false;
if (rel.startsWith("app/api/") || rel.startsWith("generated/")) return false;
return (
rel.startsWith("components/") ||
rel.startsWith("hooks/") ||
rel.startsWith("app/")
);
}
function writeSites(source: string): string[] {
const out: string[] = [];
for (const re of [HELPER_WRITE, RAW_WRITE]) {
for (const m of source.matchAll(re)) {
if (isAiInputEndpoint(m[1]!)) out.push(m[1]!);
}
}
return out;
}
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(inScope);
const sites = files
.map((rel) => {
const source = readFileSync(join(SRC, rel), "utf8");
return {
rel,
writes: writeSites(source),
bundleCalls: [...source.matchAll(BUNDLE_CALL)].length,
};
})
.filter((s) => s.writes.length > 0);
describe("AI-input writes evict the capability answer", () => {
it("the bundle carries the account payload every AI surface reads", () => {
expect(aiInputDependentKeys).toContainEqual(queryKeys.authMe());
});
it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => {
const total = sites.reduce((n, s) => n + s.writes.length, 0);
expect(sites.length).toBeGreaterThanOrEqual(21);
expect(total).toBeGreaterThanOrEqual(22);
});
it.each(sites.map((s) => [s.rel, s] as const))(
"%s invalidates aiInputDependentKeys",
(_rel, site) => {
expect(
site.bundleCalls,
`writes to ${site.writes.join(", ")}`,
).toBeGreaterThanOrEqual(1);
},
);
});