diff --git a/src/__tests__/derived-response-consumer-guard.test.ts b/src/__tests__/derived-response-consumer-guard.test.ts new file mode 100644 index 000000000..08e33261b --- /dev/null +++ b/src/__tests__/derived-response-consumer-guard.test.ts @@ -0,0 +1,1026 @@ +/** + * Per-envelope response/consumer guard for `GET /api/insights/derived`. + * + * The registered Zod response schema is the producer. Each meaningful leaf is + * tied to the derived-insights component that reads it, or to one exact + * documented wire-only exception. The matcher never searches for an unqualified + * field name, so generic names such as `status`, `source`, and `score` cannot be + * satisfied by another response family. + * + * This is one per-envelope adoption point, not a claim that every GET response + * in the application has the same protection. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const SCHEMA_FILE = join(ROOT, "src/lib/openapi/routes/insights/schemas.ts"); +const PATH_FILE = join(ROOT, "src/lib/openapi/routes/insights/paths.ts"); +const ROUTE_FILE = join(ROOT, "src/app/api/insights/derived/route.ts"); +const BATCH_ROUTE_FILE = join( + ROOT, + "src/app/api/insights/derived/batch/route.ts", +); +const DERIVED_TYPES_FILE = join(ROOT, "src/lib/insights/derived/types.ts"); +const ASSESSMENT_TYPES_FILE = join( + ROOT, + "src/lib/insights/derived/derived-assessment.ts", +); +const DERIVED_COMPONENTS = join(ROOT, "src/components/insights/derived"); +const DERIVED_BATCH_CLIENT = join(DERIVED_COMPONENTS, "use-derived-metric.ts"); +const VITALS_DASHBOARD = join(DERIVED_COMPONENTS, "vitals-dashboard.tsx"); + +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + source(path), + ts.ScriptTarget.Latest, + true, + path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function propertyName( + name: ts.PropertyName, + file: ts.SourceFile, +): string | null { + if (ts.isComputedPropertyName(name)) return null; + return name.getText(file).replace(/^['"]|['"]$/g, ""); +} + +function expressionAccessPath(expression: ts.Expression): string | null { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) { + const base = expressionAccessPath(expression.expression); + return base === null ? null : `${base}.${expression.name.text}`; + } + if (ts.isElementAccessExpression(expression)) { + const base = expressionAccessPath(expression.expression); + const key = expression.argumentExpression; + if ( + base !== null && + key && + (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) + ) { + return `${base}.${key.text}`; + } + return null; + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return expressionAccessPath(expression.expression); + } + return null; +} + +function executableAccesses(path: string): Set { + const accesses = new Set(); + const file = parse(path); + function visit(node: ts.Node): void { + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const access = expressionAccessPath(node); + if (access !== null) accesses.add(access); + } + ts.forEachChild(node, visit); + } + visit(file); + return accesses; +} + +interface ZodObjectShape { + leaves: string[]; + arrays: string[]; +} + +const TRANSPARENT_ZOD_METHODS = [ + "meta", + "describe", + "nullable", + "optional", + "default", + "catch", + "readonly", + "brand", +] as const; + +const LEAF_ZOD_METHODS = [ + "string", + "number", + "boolean", + "enum", + "literal", + "record", + "datetime", + "int", +] as const; + +function zodObjectShape(path: string, schemaName: string): ZodObjectShape { + const file = parse(path); + const initializers = new Map(); + const arrays = new Set(); + for (const statement of file.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + initializers.set(declaration.name.text, declaration.initializer); + } + } + } + + function collect( + expression: ts.Expression, + prefix: string, + seen: Set, + ): string[] { + let current = expression; + while (ts.isCallExpression(current)) { + const callee = current.expression; + expect( + ts.isPropertyAccessExpression(callee), + `${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`, + ).toBe(true); + if (!ts.isPropertyAccessExpression(callee)) return [prefix]; + const method = callee.name.text; + if (method === "object") { + const object = current.arguments[0]; + expect( + object && ts.isObjectLiteralExpression(object), + `${prefix || schemaName} must use a literal z.object shape`, + ).toBe(true); + return (object as ts.ObjectLiteralExpression).properties.flatMap( + (property) => { + expect( + ts.isPropertyAssignment(property), + `${prefix || schemaName} contains unsupported Zod object syntax: ${property.getText(file)}`, + ).toBe(true); + if (!ts.isPropertyAssignment(property)) return []; + const name = propertyName(property.name, file); + expect( + name, + `${prefix || schemaName} contains a computed property name`, + ).not.toBeNull(); + if (name === null) return []; + const child = prefix ? `${prefix}.${name}` : name; + return collect(property.initializer, child, new Set(seen)); + }, + ); + } + if (method === "array") { + const item = current.arguments[0]; + expect( + item, + `${prefix} array must declare its item schema`, + ).toBeDefined(); + arrays.add(prefix); + return collect(item!, prefix, new Set(seen)); + } + if ( + LEAF_ZOD_METHODS.includes(method as (typeof LEAF_ZOD_METHODS)[number]) + ) { + return [prefix]; + } + expect( + TRANSPARENT_ZOD_METHODS.includes( + method as (typeof TRANSPARENT_ZOD_METHODS)[number], + ), + `${prefix || schemaName} uses unsupported or shape-changing Zod method .${method}()`, + ).toBe(true); + if ( + !TRANSPARENT_ZOD_METHODS.includes( + method as (typeof TRANSPARENT_ZOD_METHODS)[number], + ) + ) { + return [prefix]; + } + current = callee.expression; + } + + if (ts.isIdentifier(current)) { + const referenced = initializers.get(current.text); + if (referenced) { + expect( + seen.has(current.text), + `cyclic Zod schema reference at ${current.text}`, + ).toBe(false); + return collect(referenced, prefix, new Set(seen).add(current.text)); + } + expect( + false, + `${prefix || schemaName} references unknown schema ${current.text}`, + ).toBe(true); + } + return [prefix]; + } + + const root = initializers.get(schemaName); + expect(root, `${schemaName} must remain a declared Zod schema`).toBeDefined(); + return { + leaves: [...new Set(collect(root!, "", new Set([schemaName])))].sort(), + arrays: [...arrays].sort(), + }; +} + +function apiSuccessObjectBranches(path: string): string[][] { + const file = parse(path); + const branches: string[][] = []; + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" + ) { + const payload = node.arguments[0]; + expect( + payload && ts.isObjectLiteralExpression(payload), + `${path} apiSuccess payload must remain an object literal`, + ).toBe(true); + if (payload && ts.isObjectLiteralExpression(payload)) { + const fields = payload.properties.flatMap((property) => { + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `${path} contains unsupported apiSuccess syntax: ${property.getText(file)}`, + ).toBe(true); + if ( + !ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) + ) { + return []; + } + const name = propertyName(property.name, file); + expect(name, `${path} has a computed response field`).not.toBeNull(); + return name === null ? [] : [name]; + }); + branches.push([...new Set(fields)]); + } + } + ts.forEachChild(node, visit); + } + + visit(file); + + expect( + branches.length, + `${path} must still return at least one apiSuccess(...) branch`, + ).toBeGreaterThan(0); + return branches; +} +function assertPathResponseEnvelope( + path: string, + apiPath: string, + schemaName: string, + envelopeName: string, +): void { + const file = parse(path); + const routeProperties: ts.PropertyAssignment[] = []; + function findRoute(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + propertyName(node.name, file) === apiPath + ) { + routeProperties.push(node); + return; + } + ts.forEachChild(node, findRoute); + } + findRoute(file); + expect( + routeProperties, + `${apiPath} must have exactly one OpenAPI path entry`, + ).toHaveLength(1); + + const envelopes: ts.CallExpression[] = []; + function findEnvelope(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "dataEnvelope" + ) { + envelopes.push(node); + } + ts.forEachChild(node, findEnvelope); + } + findEnvelope(routeProperties[0].initializer); + expect( + envelopes, + `${apiPath} must register exactly one response envelope`, + ).toHaveLength(1); + const envelope = envelopes[0]; + expect( + envelope.arguments.length === 2 && + ts.isIdentifier(envelope.arguments[0]) && + envelope.arguments[0].text === schemaName && + ts.isStringLiteral(envelope.arguments[1]) && + envelope.arguments[1].text === envelopeName, + `${apiPath} must register ${schemaName} as ${envelopeName}`, + ).toBe(true); +} + +function interfaceShape(path: string, interfaceName: string): ZodObjectShape { + const file = parse(path); + const declaration = file.statements.find( + (statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && + statement.name.text === interfaceName, + ); + expect( + declaration, + `${interfaceName} must remain an interface in ${path}`, + ).toBeDefined(); + const leaves: string[] = []; + const arrays: string[] = []; + for (const member of declaration!.members) { + expect( + ts.isPropertySignature(member) && + member.name !== undefined && + member.type, + `${interfaceName} contains unsupported member syntax`, + ).toBeTruthy(); + if ( + !ts.isPropertySignature(member) || + member.name === undefined || + !member.type + ) { + continue; + } + const name = propertyName(member.name, file); + expect(name, `${interfaceName} has a computed field`).not.toBeNull(); + if (name === null) continue; + leaves.push(name); + if ( + ts.isArrayTypeNode(member.type) || + (ts.isTypeReferenceNode(member.type) && + ts.isIdentifier(member.type.typeName) && + member.type.typeName.text === "Array") + ) { + arrays.push(name); + } + } + return { leaves: leaves.sort(), arrays: arrays.sort() }; +} + +function expressionLineage(expression: ts.Expression): string[] { + const accesses = new Set(); + function visit(node: ts.Node): void { + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const access = expressionAccessPath(node); + if (access !== null) accesses.add(access); + } + ts.forEachChild(node, visit); + } + visit(expression); + if (ts.isIdentifier(expression)) accesses.add(expression.text); + return [...accesses].sort(); +} + +function apiSuccessLineageBranches( + path: string, +): Array> { + const file = parse(path); + const branches: Array> = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" + ) { + const payload = node.arguments[0]; + expect( + payload && ts.isObjectLiteralExpression(payload), + `${path} apiSuccess payload must remain an object literal`, + ).toBe(true); + if (payload && ts.isObjectLiteralExpression(payload)) { + const lineage: Record = {}; + for (const property of payload.properties) { + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `${path} contains unsupported apiSuccess syntax: ${property.getText(file)}`, + ).toBe(true); + if (ts.isPropertyAssignment(property)) { + const name = propertyName(property.name, file); + expect( + name, + `${path} has a computed response field`, + ).not.toBeNull(); + if (name !== null) { + lineage[name] = expressionLineage(property.initializer); + } + } else if (ts.isShorthandPropertyAssignment(property)) { + lineage[property.name.text] = [property.name.text]; + } + } + branches.push(lineage); + } + } + ts.forEachChild(node, visit); + } + visit(file); + expect(branches.length).toBeGreaterThan(0); + return branches; +} + +function variableCall(path: string, variableName: string): string | null { + const file = parse(path); + let callee: string | null = null; + function visit(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === variableName && + node.initializer + ) { + expect(callee).toBeNull(); + let initializer = node.initializer; + while ( + ts.isAwaitExpression(initializer) || + ts.isParenthesizedExpression(initializer) + ) { + initializer = initializer.expression; + } + if ( + ts.isCallExpression(initializer) && + ts.isIdentifier(initializer.expression) + ) { + callee = initializer.expression.text; + } + } + ts.forEachChild(node, visit); + } + visit(file); + return callee; +} + +function expressionReferences(expression: ts.Expression): string[] { + const references = new Set(expressionLineage(expression)); + function visit(node: ts.Node): void { + if (ts.isIdentifier(node) && node.text !== "undefined") { + const parent = node.parent; + const belongsToAccess = + (ts.isPropertyAccessExpression(parent) && + (parent.expression === node || parent.name === node)) || + (ts.isElementAccessExpression(parent) && + (parent.expression === node || parent.argumentExpression === node)); + if (!belongsToAccess) references.add(node.text); + } + ts.forEachChild(node, visit); + } + visit(expression); + return [...references].sort(); +} + +function assertJsxHandoff( + path: string, + componentName: string, + expected: Readonly>, +): void { + const file = parse(path); + const handoffs: Array> = []; + function visit(node: ts.Node): void { + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + ts.isIdentifier(node.tagName) && + node.tagName.text === componentName + ) { + const handoff: Record = {}; + for (const propName of Object.keys(expected)) { + const attribute = node.attributes.properties.find( + (property) => + ts.isJsxAttribute(property) && + ts.isIdentifier(property.name) && + property.name.text === propName, + ); + if ( + attribute && + ts.isJsxAttribute(attribute) && + attribute.initializer && + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression + ) { + handoff[propName] = expressionReferences( + attribute.initializer.expression, + ); + } + } + handoffs.push(handoff); + } + ts.forEachChild(node, visit); + } + visit(file); + const exactHandoffs = handoffs.filter( + (handoff) => JSON.stringify(handoff) === JSON.stringify(expected), + ); + expect( + exactHandoffs, + `${componentName} must receive the derived envelope fields exactly once`, + ).toHaveLength(1); +} + +function calledIdentifiers(node: ts.Node): string[] { + const calls = new Set(); + function visit(current: ts.Node): void { + if (ts.isCallExpression(current) && ts.isIdentifier(current.expression)) { + calls.add(current.expression.text); + } + ts.forEachChild(current, visit); + } + visit(node); + return [...calls].sort(); +} + +function namedVariable( + root: ts.Node, + variableName: string, +): ts.VariableDeclaration { + const matches: ts.VariableDeclaration[] = []; + function visit(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === variableName + ) { + matches.push(node); + } + ts.forEachChild(node, visit); + } + visit(root); + expect( + matches, + `${variableName} must have exactly one declaration`, + ).toHaveLength(1); + return matches[0]; +} + +function assertBatchSchemaReferencesSingleShape(path: string): void { + const file = parse(path); + const declaration = namedVariable(file, "derivedBatchResponse"); + expect(declaration.initializer).toBeDefined(); + + const metricsProperties: ts.PropertyAssignment[] = []; + function findMetrics(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + propertyName(node.name, file) === "metrics" + ) { + metricsProperties.push(node); + return; + } + ts.forEachChild(node, findMetrics); + } + findMetrics(declaration.initializer!); + expect( + metricsProperties, + "derivedBatchResponse must declare exactly one metrics property", + ).toHaveLength(1); + + const recordCalls: ts.CallExpression[] = []; + function findRecord(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "z" && + node.expression.name.text === "record" + ) { + recordCalls.push(node); + } + ts.forEachChild(node, findRecord); + } + findRecord(metricsProperties[0].initializer); + expect( + recordCalls, + "derivedBatchResponse.metrics must contain one record value schema", + ).toHaveLength(1); + const record = recordCalls[0]; + expect( + record.arguments.length === 2 && + ts.isIdentifier(record.arguments[1]) && + record.arguments[1].text === "derivedMetricResponse", + "derivedBatchResponse.metrics values must use derivedMetricResponse", + ).toBe(true); +} + +function assertBatchProducerLineage( + path: string, + responseFields: readonly string[], +): void { + const file = parse(path); + const build = file.statements.find( + (statement): statement is ts.FunctionDeclaration => + ts.isFunctionDeclaration(statement) && + statement.name?.text === "buildDerivedBatch", + ); + expect( + build?.body, + "buildDerivedBatch must remain a declared function", + ).toBeDefined(); + + const payloads: ts.ObjectLiteralExpression[] = []; + let mapAssignment = false; + let returnsMap = false; + function visitBuild(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + propertyName(node.name, file) === "payload" + ) { + expect( + ts.isObjectLiteralExpression(node.initializer), + "buildDerivedBatch payload must remain an object literal", + ).toBe(true); + if (ts.isObjectLiteralExpression(node.initializer)) { + payloads.push(node.initializer); + } + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isElementAccessExpression(node.left) && + ts.isIdentifier(node.left.expression) && + node.left.expression.text === "map" && + node.left.argumentExpression !== undefined && + expressionAccessPath(node.left.argumentExpression) === "r.key" && + expressionAccessPath(node.right) === "r.payload" + ) { + mapAssignment = true; + } + if ( + ts.isReturnStatement(node) && + node.expression && + ts.isIdentifier(node.expression) && + node.expression.text === "map" + ) { + returnsMap = true; + } + ts.forEachChild(node, visitBuild); + } + visitBuild(build!.body!); + expect( + payloads, + "buildDerivedBatch must construct one metric payload", + ).toHaveLength(1); + const payload = payloads[0]; + const lineage: Record = {}; + for (const property of payload.properties) { + expect( + ts.isPropertyAssignment(property), + `batch payload contains unsupported syntax: ${property.getText(file)}`, + ).toBe(true); + if (!ts.isPropertyAssignment(property)) continue; + const name = propertyName(property.name, file); + expect(name, "batch payload has a computed field").not.toBeNull(); + if (name !== null) { + lineage[name] = expressionReferences(property.initializer); + } + } + expect(Object.keys(lineage).sort()).toEqual([...responseFields].sort()); + expect(lineage).toEqual({ + metric: ["item.metric"], + status: ["derived.status"], + value: ["derived.status", "derived.value"], + coverage: ["derived.coverage"], + confidence: ["derived.confidence", "derived.status"], + provenance: ["derived.provenance"], + reason: ["derived.reason", "derived.status"], + assessment: ["derived", "item.metric", "safeAssessment"], + }); + expect( + mapAssignment, + "batch payload must be stored under its request token", + ).toBe(true); + expect(returnsMap, "buildDerivedBatch must return the populated map").toBe( + true, + ); + + const handlerMaps: ts.VariableDeclaration[] = []; + function findHandlerMap(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "map" && + node.initializer && + calledIdentifiers(node.initializer).includes("cachedSwr") + ) { + handlerMaps.push(node); + } + ts.forEachChild(node, findHandlerMap); + } + findHandlerMap(file); + expect( + handlerMaps, + "route map must have one cachedSwr declaration", + ).toHaveLength(1); + expect( + calledIdentifiers(handlerMaps[0].initializer!), + "the cached map must build through buildDerivedBatch", + ).toContain("buildDerivedBatch"); + + const gatedMetrics = namedVariable(file, "gatedMetrics"); + expect( + expressionReferences(gatedMetrics.initializer!), + "gatedMetrics must filter the built map", + ).toContain("map"); + + const branches = apiSuccessLineageBranches(path); + expect(branches, "batch route must return one response branch").toEqual([ + { metrics: ["gatedMetrics"] }, + ]); +} + +function assertBatchClientHandoff( + hookPath: string, + dashboardPath: string, +): void { + const hook = parse(hookPath); + const batchFetches: ts.CallExpression[] = []; + let readFunction: ts.FunctionDeclaration | undefined; + function visitHook(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiGet" && + node.arguments[0]?.getText(hook) === + "`/api/insights/derived/batch?${params.toString()}`" + ) { + batchFetches.push(node); + } + if (ts.isFunctionDeclaration(node) && node.name?.text === "read") { + expect( + readFunction, + "batch selector must have one read function", + ).toBeUndefined(); + readFunction = node; + } + ts.forEachChild(node, visitHook); + } + visitHook(hook); + expect( + batchFetches, + "derived batch hook must fetch the batch endpoint exactly once", + ).toHaveLength(1); + expect( + readFunction?.body, + "batch hook must declare its read selector", + ).toBeDefined(); + + const entry = namedVariable(readFunction!.body!, "entry"); + expect( + entry.initializer && ts.isElementAccessExpression(entry.initializer), + "batch read selector must index the response metrics map", + ).toBe(true); + if (entry.initializer && ts.isElementAccessExpression(entry.initializer)) { + expect(expressionAccessPath(entry.initializer.expression)).toBe( + "query.data.metrics", + ); + const token = entry.initializer.argumentExpression; + expect( + token && + ts.isCallExpression(token) && + ts.isIdentifier(token.expression) && + token.expression.text === "tokenString" && + token.arguments.length === 1 && + ts.isIdentifier(token.arguments[0]) && + token.arguments[0].text === "token", + "batch read selector must use the requested token", + ).toBe(true); + } + const returnsEntry = readFunction!.body!.statements.some( + (statement) => + ts.isReturnStatement(statement) && + statement.expression !== undefined && + expressionReferences(statement.expression).includes("entry"), + ); + expect( + returnsEntry, + "batch read selector must return its selected entry", + ).toBe(true); + + const dashboard = parse(dashboardPath); + const readHandoffs: ts.VariableDeclaration[] = []; + function visitDashboard(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "read" && + node.initializer && + expressionAccessPath(node.initializer) === "batch.read" + ) { + readHandoffs.push(node); + } + ts.forEachChild(node, visitDashboard); + } + visitDashboard(dashboard); + expect( + readHandoffs, + "VitalsDashboard must consume the batch read selector exactly once", + ).toHaveLength(1); +} + +interface ConsumerBinding { + file: string; + accesses: readonly string[]; +} + +const CONSUMERS: Readonly> = { + status: { + file: join(DERIVED_COMPONENTS, "vitals-dashboard.tsx"), + accesses: ["data.status"], + }, + value: { + file: join(DERIVED_COMPONENTS, "vitals-dashboard.tsx"), + accesses: ["data.value"], + }, + reason: { + file: join(DERIVED_COMPONENTS, "vitals-dashboard.tsx"), + accesses: ["data.reason"], + }, + "coverage.requiredInputs": { + file: join(DERIVED_COMPONENTS, "coverage-meter.tsx"), + accesses: ["coverage.requiredInputs"], + }, + "coverage.presentInputs": { + file: join(DERIVED_COMPONENTS, "coverage-meter.tsx"), + accesses: ["coverage.presentInputs"], + }, + "coverage.historyDays": { + file: join(DERIVED_COMPONENTS, "coverage-meter.tsx"), + accesses: ["coverage.historyDays"], + }, + "coverage.missing": { + file: join(DERIVED_COMPONENTS, "coverage-meter.tsx"), + accesses: ["coverage.missing"], + }, + "confidence.score": { + file: join(DERIVED_COMPONENTS, "coverage-meter.tsx"), + accesses: ["confidence.score"], + }, + "confidence.band": { + file: join(DERIVED_COMPONENTS, "coverage-meter.tsx"), + accesses: ["confidence.band"], + }, + "assessment.text": { + file: join(DERIVED_COMPONENTS, "composite-score-anatomy.tsx"), + accesses: ["assessment.text"], + }, + "assessment.updatedAt": { + file: join(DERIVED_COMPONENTS, "composite-score-anatomy.tsx"), + accesses: ["assessment.updatedAt"], + }, +}; + +const WIRE_ONLY_METRIC = + "The metric id intentionally tags the public union for programmatic and native callers. The web already keys each read by the requested metric token and does not need the echo."; +const WIRE_ONLY_PROVENANCE = + "The public response intentionally preserves machine-readable provenance for programmatic and native callers. The web uses curated method and citation copy rather than rendering raw source metadata."; +const WIRE_ONLY_ASSESSMENT_SOURCE = + "The public response intentionally identifies deterministic versus cached assessment prose for programmatic and native callers. The web presents the prose and timestamp without exposing its implementation source."; + +const EXCEPTIONS: Readonly> = { + metric: WIRE_ONLY_METRIC, + "provenance.inputs": WIRE_ONLY_PROVENANCE, + "provenance.source": WIRE_ONLY_PROVENANCE, + "provenance.windowDays": WIRE_ONLY_PROVENANCE, + "provenance.computedAt": WIRE_ONLY_PROVENANCE, + "assessment.source": WIRE_ONLY_ASSESSMENT_SOURCE, +}; + +describe("derived response consumer guard", () => { + const { leaves, arrays } = zodObjectShape( + SCHEMA_FILE, + "derivedMetricResponse", + ); + + it("anchors the guard to the registered single-metric producer", () => { + assertPathResponseEnvelope( + PATH_FILE, + "/api/insights/derived", + "derivedMetricResponse", + "DerivedMetricResponseEnvelope", + ); + assertPathResponseEnvelope( + PATH_FILE, + "/api/insights/derived/batch", + "derivedBatchResponse", + "DerivedBatchResponseEnvelope", + ); + expect(leaves.length).toBeGreaterThan(15); + expect(arrays).toEqual(["coverage.missing", "provenance.inputs"]); + const responseFields = [ + ...new Set(leaves.map((leaf) => leaf.split(".", 1)[0])), + ].sort(); + expect(responseFields).toContain("assessment"); + for (const [index, fields] of apiSuccessObjectBranches( + ROUTE_FILE, + ).entries()) { + expect( + fields.sort(), + `derived apiSuccess branch ${index + 1} must expose every registered response field`, + ).toEqual(responseFields); + } + const expectedLineage: Record = { + metric: ["metric"], + status: ["derived.status"], + value: ["derived.status", "derived.value"], + coverage: ["derived.coverage"], + confidence: ["derived.confidence", "derived.status"], + provenance: ["derived.provenance"], + reason: ["derived.reason", "derived.status"], + assessment: ["assessment"], + }; + for (const [index, lineage] of apiSuccessLineageBranches( + ROUTE_FILE, + ).entries()) { + expect( + lineage, + `derived apiSuccess branch ${index + 1} must preserve producer lineage`, + ).toEqual(expectedLineage); + } + expect(variableCall(ROUTE_FILE, "derived")).toBe("computeDerivedMetric"); + expect(variableCall(ROUTE_FILE, "assessment")).toBe( + "resolveDerivedAssessment", + ); + + const nestedShapes = [ + ["derivedCoverage", DERIVED_TYPES_FILE, "DerivedCoverage"], + ["derivedConfidence", DERIVED_TYPES_FILE, "DerivedConfidence"], + ["derivedProvenance", DERIVED_TYPES_FILE, "DerivedProvenance"], + ["derivedAssessment", ASSESSMENT_TYPES_FILE, "DerivedAssessment"], + ] as const; + for (const [schemaName, typeFile, interfaceName] of nestedShapes) { + expect( + zodObjectShape(SCHEMA_FILE, schemaName), + `${schemaName} must match the value object that reaches the route`, + ).toEqual(interfaceShape(typeFile, interfaceName)); + } + + assertJsxHandoff( + join(DERIVED_COMPONENTS, "composite-score-anatomy.tsx"), + "ScoreAnatomyView", + { + coverage: ["data.coverage"], + confidence: ["data.confidence"], + }, + ); + assertJsxHandoff( + join(DERIVED_COMPONENTS, "score-anatomy-view.tsx"), + "CoverageMeter", + { + coverage: ["coverage"], + confidence: ["confidence"], + }, + ); + assertBatchSchemaReferencesSingleShape(SCHEMA_FILE); + assertBatchProducerLineage(BATCH_ROUTE_FILE, responseFields); + assertBatchClientHandoff(DERIVED_BATCH_CLIENT, VITALS_DASHBOARD); + }); + + it("accounts for every meaningful schema leaf without a generic sweep", () => { + const accounted = [ + ...Object.keys(CONSUMERS), + ...Object.keys(EXCEPTIONS), + ].sort(); + expect( + accounted, + "every registered derived-response leaf needs an exact consumer or exception", + ).toEqual(leaves); + + const accessesByFile = new Map>(); + for (const [leaf, binding] of Object.entries(CONSUMERS)) { + const accesses = + accessesByFile.get(binding.file) ?? executableAccesses(binding.file); + accessesByFile.set(binding.file, accesses); + for (const access of binding.accesses) { + expect( + accesses.has(access), + `${leaf} no longer reads ${access} in ${binding.file}`, + ).toBe(true); + } + } + }); + + it("keeps every wire-only exception exact and non-stale", () => { + for (const [leaf, reason] of Object.entries(EXCEPTIONS)) { + expect(leaves, `${leaf} is excepted but no longer returned`).toContain( + leaf, + ); + expect(reason.length).toBeGreaterThan(60); + } + }); +}); diff --git a/src/__tests__/integration-failure-response-consumer-guard.test.ts b/src/__tests__/integration-failure-response-consumer-guard.test.ts new file mode 100644 index 000000000..9d5438e17 --- /dev/null +++ b/src/__tests__/integration-failure-response-consumer-guard.test.ts @@ -0,0 +1,765 @@ +/** + * Per-envelope response/consumer guard for the failure visibility contracts + * exposed by integration status, admin status, and the environment overview. + * + * This deliberately does not sweep every response field in the application. + * Each producer declaration below is paired with the one web subtree that owns + * its presentation, and every assertion uses an envelope-qualified access. A + * generic name such as `state` or `failures` elsewhere cannot satisfy it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const STATUS_SCHEMA = join(ROOT, "src/lib/integrations/status.ts"); +const JOB_FAILURE_SCHEMA = join(ROOT, "src/lib/jobs/job-failures.ts"); +const INTEGRATION_ROUTE = join( + ROOT, + "src/app/api/integrations/status/route.ts", +); +const ADMIN_ROUTE = join(ROOT, "src/app/api/admin/status/route.ts"); +const ENVIRONMENT_ROUTE = join(ROOT, "src/app/api/environment/route.ts"); +const INTEGRATION_CONSUMER = join( + ROOT, + "src/components/settings/integrations/shared.tsx", +); +const ADMIN_CONSUMER = join( + ROOT, + "src/components/admin/system-status-section.tsx", +); +const ENVIRONMENT_CONSUMER = join( + ROOT, + "src/components/settings/environment-section.tsx", +); + +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + source(path), + ts.ScriptTarget.Latest, + true, + path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function expressionAccessPath(expression: ts.Expression): string | null { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) { + const base = expressionAccessPath(expression.expression); + return base === null ? null : `${base}.${expression.name.text}`; + } + if (ts.isElementAccessExpression(expression)) { + const base = expressionAccessPath(expression.expression); + const key = expression.argumentExpression; + if ( + base !== null && + key && + (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) + ) { + return `${base}.${key.text}`; + } + return null; + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return expressionAccessPath(expression.expression); + } + return null; +} + +function executableAccesses(path: string): Set { + const accesses = new Set(); + const file = parse(path); + function visit(node: ts.Node): void { + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const access = expressionAccessPath(node); + if (access !== null) accesses.add(access); + } + ts.forEachChild(node, visit); + } + visit(file); + return accesses; +} + +function interfaceFields(path: string, interfaceName: string): string[] { + const file = parse(path); + const declaration = file.statements.find( + (statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && + statement.name.text === interfaceName, + ); + expect( + declaration, + `${interfaceName} must remain an interface in ${path}`, + ).toBeDefined(); + return declaration!.members.flatMap((member) => { + if (!ts.isPropertySignature(member) || member.name === undefined) return []; + return [member.name.getText(file).replace(/^['"]|['"]$/g, "")]; + }); +} + +function stringUnionMembers(path: string, typeName: string): string[] { + const file = parse(path); + const declaration = file.statements.find( + (statement): statement is ts.TypeAliasDeclaration => + ts.isTypeAliasDeclaration(statement) && statement.name.text === typeName, + ); + expect( + declaration, + `${typeName} must remain a type alias in ${path}`, + ).toBeDefined(); + expect(ts.isUnionTypeNode(declaration!.type)).toBe(true); + return (declaration!.type as ts.UnionTypeNode).types.flatMap((member) => + ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal) + ? [member.literal.text] + : [], + ); +} + +function propertyName( + name: ts.PropertyName, + file: ts.SourceFile, +): string | null { + if (ts.isComputedPropertyName(name)) return null; + return name.getText(file).replace(/^['"]|['"]$/g, ""); +} + +function namedFunctionReturnFields( + file: ts.SourceFile, + functionName: string, +): string[] { + let declaration: ts.FunctionDeclaration | undefined; + function find(node: ts.Node): void { + if (ts.isFunctionDeclaration(node) && node.name?.text === functionName) { + expect( + declaration, + `${functionName} must have one declaration`, + ).toBeUndefined(); + declaration = node; + } + ts.forEachChild(node, find); + } + find(file); + expect( + declaration?.body, + `${functionName} must remain a declared function with a body`, + ).toBeDefined(); + + const branches: string[][] = []; + function collectReturns(node: ts.Node): void { + if (ts.isReturnStatement(node)) { + expect( + node.expression && ts.isObjectLiteralExpression(node.expression), + `${functionName} must return an object literal`, + ).toBe(true); + if (node.expression && ts.isObjectLiteralExpression(node.expression)) { + branches.push( + node.expression.properties.flatMap((property) => { + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `${functionName} contains unsupported return syntax: ${property.getText(file)}`, + ).toBe(true); + if ( + !ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) + ) { + return []; + } + const name = propertyName(property.name, file); + expect( + name, + `${functionName} has a computed response field`, + ).not.toBeNull(); + return name === null ? [] : [name]; + }), + ); + } + return; + } + ts.forEachChild(node, collectReturns); + } + collectReturns(declaration!.body!); + expect( + branches.length, + `${functionName} must return a payload`, + ).toBeGreaterThan(0); + for (const [index, branch] of branches.entries()) { + expect( + branch.sort(), + `${functionName} return branch ${index + 1} must preserve one shape`, + ).toEqual([...branches[0]].sort()); + } + return branches[0]; +} + +function namedFunctionReturnLineage( + file: ts.SourceFile, + functionName: string, +): Record { + let declaration: ts.FunctionDeclaration | undefined; + function find(node: ts.Node): void { + if (ts.isFunctionDeclaration(node) && node.name?.text === functionName) { + expect( + declaration, + `${functionName} must have one declaration`, + ).toBeUndefined(); + declaration = node; + } + ts.forEachChild(node, find); + } + find(file); + expect(declaration?.body).toBeDefined(); + + const branches: Array> = []; + function collectReturns(node: ts.Node): void { + if (ts.isReturnStatement(node)) { + expect( + node.expression && ts.isObjectLiteralExpression(node.expression), + `${functionName} must return an object literal`, + ).toBe(true); + if (node.expression && ts.isObjectLiteralExpression(node.expression)) { + const lineage: Record = {}; + for (const property of node.expression.properties) { + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `${functionName} contains unsupported return syntax: ${property.getText(file)}`, + ).toBe(true); + if (ts.isPropertyAssignment(property)) { + const name = propertyName(property.name, file); + expect(name).not.toBeNull(); + if (name !== null) { + lineage[name] = expressionAccessPath(property.initializer); + } + } else if (ts.isShorthandPropertyAssignment(property)) { + lineage[property.name.text] = property.name.text; + } + } + branches.push(lineage); + } + return; + } + ts.forEachChild(node, collectReturns); + } + collectReturns(declaration!.body!); + expect(branches.length).toBeGreaterThan(0); + for (const [index, branch] of branches.entries()) { + expect( + branch, + `${functionName} return branch ${index + 1} must preserve one lineage`, + ).toEqual(branches[0]); + } + return branches[0]; +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + } + return current; +} + +function integrationPublishFlow(path: string): { + publishedFields: string[]; + publishedSnapshots: string[]; + entries: number; +} { + const file = parse(path); + const publishedFields = namedFunctionReturnFields(file, "publish"); + const publishedLineage = namedFunctionReturnLineage(file, "publish"); + expect(Object.keys(publishedLineage).sort()).toEqual( + [...publishedFields].sort(), + ); + for (const field of publishedFields) { + expect( + publishedLineage[field], + `publish.${field} must come from snapshot.${field}`, + ).toBe(`snapshot.${field}`); + } + const resolvedFields = namedFunctionReturnFields(file, "resolve"); + expect( + publishedFields.filter((field) => resolvedFields.includes(field)), + "resolve(...) must not override a field supplied by publish(...)", + ).toEqual([]); + + let apiSuccessBranches = 0; + let entries = 0; + const publishedSnapshots: string[] = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" + ) { + apiSuccessBranches += 1; + const payload = node.arguments[0]; + expect( + payload && ts.isObjectLiteralExpression(payload), + "integration status apiSuccess payload must remain an object literal", + ).toBe(true); + if (!payload || !ts.isObjectLiteralExpression(payload)) return; + + const threshold = payload.properties.find( + (property) => + ts.isPropertyAssignment(property) && + propertyName(property.name, file) === "threshold", + ); + expect( + threshold && ts.isPropertyAssignment(threshold), + "integration status must expose threshold", + ).toBe(true); + if (threshold && ts.isPropertyAssignment(threshold)) { + expect( + ts.isCallExpression(threshold.initializer) && + ts.isIdentifier(threshold.initializer.expression) && + threshold.initializer.expression.text === + "getPersistentFailureThreshold", + "threshold must come from getPersistentFailureThreshold()", + ).toBe(true); + } + + const integrations = payload.properties.find( + (property) => + ts.isPropertyAssignment(property) && + propertyName(property.name, file) === "integrations", + ); + expect( + integrations && ts.isPropertyAssignment(integrations), + "integration status must expose integrations", + ).toBe(true); + if (!integrations || !ts.isPropertyAssignment(integrations)) return; + expect(ts.isArrayLiteralExpression(integrations.initializer)).toBe(true); + if (!ts.isArrayLiteralExpression(integrations.initializer)) return; + + for (const element of integrations.initializer.elements) { + const entry = unwrapExpression(element); + expect( + ts.isObjectLiteralExpression(entry), + "every integration response entry must remain an object literal", + ).toBe(true); + if (!ts.isObjectLiteralExpression(entry)) continue; + entries += 1; + let publishSpreads = 0; + let publishSeen = false; + for (const property of entry.properties) { + if (ts.isSpreadAssignment(property)) { + expect( + ts.isCallExpression(property.expression) && + ts.isIdentifier(property.expression.expression), + `integration entry has unsupported spread: ${property.getText(file)}`, + ).toBe(true); + if ( + !ts.isCallExpression(property.expression) || + !ts.isIdentifier(property.expression.expression) + ) { + continue; + } + const spreadName = property.expression.expression.text; + expect( + spreadName === "publish" || spreadName === "resolve", + `integration entry has unknown spread ${spreadName}(...)`, + ).toBe(true); + if (spreadName === "publish") { + publishSpreads += 1; + publishSeen = true; + expect( + property.expression.arguments.length === 1 && + ts.isIdentifier(property.expression.arguments[0]), + "publish(...) must receive one provider snapshot variable", + ).toBe(true); + if ( + property.expression.arguments.length === 1 && + ts.isIdentifier(property.expression.arguments[0]) + ) { + publishedSnapshots.push(property.expression.arguments[0].text); + } + } + continue; + } + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `integration entry has unsupported property syntax: ${property.getText(file)}`, + ).toBe(true); + if ( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) + ) { + const name = propertyName(property.name, file); + expect( + name, + "integration entry has a computed field", + ).not.toBeNull(); + if (publishSeen && name !== null) { + expect( + publishedFields, + `${name} overrides publish(...) later in the same entry`, + ).not.toContain(name); + } + } + } + expect( + publishSpreads, + "every integration response entry must spread publish(...) exactly once", + ).toBe(1); + } + } + ts.forEachChild(node, visit); + } + visit(file); + expect(apiSuccessBranches).toBeGreaterThan(0); + expect(entries).toBeGreaterThan(0); + return { publishedFields, publishedSnapshots, entries }; +} + +function assertBucketConsumerLineage( + path: string, + kinds: readonly string[], +): void { + const file = parse(path); + const declaration = file.statements.find( + (statement): statement is ts.FunctionDeclaration => + ts.isFunctionDeclaration(statement) && + statement.name?.text === "pillFailurePropsFor", + ); + expect(declaration?.body).toBeDefined(); + let bucketInitializer: string | null = null; + const accesses = new Set(); + function visit(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "buckets" && + node.initializer + ) { + expect(bucketInitializer).toBeNull(); + bucketInitializer = expressionAccessPath(node.initializer); + } + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const access = expressionAccessPath(node); + if (access !== null) accesses.add(access); + } + ts.forEachChild(node, visit); + } + visit(declaration!.body!); + expect( + bucketInitializer, + "buckets must be read from the integration status response", + ).toBe("status.consecutiveFailuresByKind"); + for (const kind of kinds) { + expect( + accesses, + `pillFailurePropsFor must read buckets.${kind} from that response value`, + ).toContain(`buckets.${kind}`); + } +} + +interface ConsumerBinding { + file: string; + accesses: readonly string[]; +} + +function assertBindings( + leaves: readonly string[], + bindings: Readonly>, +): void { + expect( + Object.keys(bindings).sort(), + "consumer bindings must exactly match the producer leaves", + ).toEqual([...leaves].sort()); + + const accessesByFile = new Map>(); + for (const leaf of leaves) { + const binding = bindings[leaf]; + expect(binding, `${leaf} has no envelope-owned web consumer`).toBeDefined(); + const accesses = + accessesByFile.get(binding.file) ?? executableAccesses(binding.file); + accessesByFile.set(binding.file, accesses); + for (const access of binding.accesses) { + expect( + accesses.has(access), + `${leaf} no longer reads ${access} in ${binding.file}`, + ).toBe(true); + } + } +} + +function assertJsxHandoff( + path: string, + componentName: string, + propName: string, + access: string, +): void { + const file = parse(path); + const handoffs: Array = []; + function visit(node: ts.Node): void { + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + ts.isIdentifier(node.tagName) && + node.tagName.text === componentName + ) { + const attribute = node.attributes.properties.find( + (property) => + ts.isJsxAttribute(property) && + ts.isIdentifier(property.name) && + property.name.text === propName, + ); + expect( + attribute && ts.isJsxAttribute(attribute), + `${componentName} must receive ${propName}`, + ).toBe(true); + if ( + attribute && + ts.isJsxAttribute(attribute) && + attribute.initializer && + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression + ) { + handoffs.push(expressionAccessPath(attribute.initializer.expression)); + } else { + handoffs.push(null); + } + } + ts.forEachChild(node, visit); + } + visit(file); + expect( + handoffs, + `${componentName}.${propName} must receive ${access} exactly once`, + ).toEqual([access]); +} + +describe("integration and background-failure response consumer guard", () => { + function assertEnvironmentFailureLineage(path: string): void { + const file = parse(path); + const bindings: Array<{ + declaration: ts.VariableDeclaration; + index: number; + }> = []; + function findBinding(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isArrayBindingPattern(node.name) + ) { + const index = node.name.elements.findIndex( + (element) => + ts.isBindingElement(element) && + ts.isIdentifier(element.name) && + element.name.text === "lastFetchFailure", + ); + if (index >= 0) bindings.push({ declaration: node, index }); + } + ts.forEachChild(node, findBinding); + } + findBinding(file); + expect( + bindings, + "lastFetchFailure must have one Promise.all binding", + ).toHaveLength(1); + const { declaration, index } = bindings[0]; + let initializer = declaration.initializer; + if (initializer && ts.isAwaitExpression(initializer)) { + initializer = initializer.expression; + } + expect( + initializer && + ts.isCallExpression(initializer) && + ts.isPropertyAccessExpression(initializer.expression) && + ts.isIdentifier(initializer.expression.expression) && + initializer.expression.expression.text === "Promise" && + initializer.expression.name.text === "all" && + initializer.arguments.length === 1 && + ts.isArrayLiteralExpression(initializer.arguments[0]), + "lastFetchFailure must be bound from Promise.all([...])", + ).toBe(true); + if ( + !initializer || + !ts.isCallExpression(initializer) || + initializer.arguments.length !== 1 || + !ts.isArrayLiteralExpression(initializer.arguments[0]) + ) { + return; + } + const sourceExpression = initializer.arguments[0].elements[index]; + expect( + sourceExpression && + ts.isCallExpression(sourceExpression) && + ts.isIdentifier(sourceExpression.expression) && + sourceExpression.expression.text === "readQueueFailureForUser" && + sourceExpression.arguments.length === 2 && + expressionAccessPath(sourceExpression.arguments[0]) === + "ENVIRONMENT_FETCH_QUEUE" && + expressionAccessPath(sourceExpression.arguments[1]) === "user.id", + "lastFetchFailure must come from the user's environment fetch queue", + ).toBe(true); + + const responseSources: Array = []; + function findResponse(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" && + node.arguments[0] && + ts.isObjectLiteralExpression(node.arguments[0]) + ) { + for (const property of node.arguments[0].properties) { + if ( + ts.isShorthandPropertyAssignment(property) && + property.name.text === "lastFetchFailure" + ) { + responseSources.push(property.name.text); + } else if ( + ts.isPropertyAssignment(property) && + propertyName(property.name, file) === "lastFetchFailure" + ) { + responseSources.push(expressionAccessPath(property.initializer)); + } + } + } + ts.forEachChild(node, findResponse); + } + findResponse(file); + expect( + responseSources, + "environment response must hand off lastFetchFailure exactly once", + ).toEqual(["lastFetchFailure"]); + } + it("pairs every integration failure-count leaf with the shared card adapter", () => { + const kinds = stringUnionMembers(STATUS_SCHEMA, "FailureKind"); + expect(kinds).toEqual(["transient", "reauth_required", "persistent"]); + + const flow = integrationPublishFlow(INTEGRATION_ROUTE); + const expectedPublishFields = interfaceFields( + STATUS_SCHEMA, + "IntegrationStatusSnapshot", + ).filter((field) => field !== "failingSince"); + expect(flow.publishedFields.sort()).toEqual(expectedPublishFields.sort()); + expect(flow.publishedFields).toContain("consecutiveFailuresByKind"); + expect(flow.entries).toBe( + stringUnionMembers(STATUS_SCHEMA, "IntegrationKey").length, + ); + const expectedSnapshots = stringUnionMembers( + STATUS_SCHEMA, + "IntegrationKey", + ).map((key) => { + const camelKey = key.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ); + return `${camelKey}Status`; + }); + expect( + flow.publishedSnapshots.sort(), + "every provider snapshot must be published exactly once", + ).toEqual(expectedSnapshots.sort()); + assertBucketConsumerLineage(INTEGRATION_CONSUMER, kinds); + + const leaves = [ + "threshold", + ...kinds.map((kind) => `consecutiveFailuresByKind.${kind}`), + ]; + assertBindings(leaves, { + threshold: { + file: INTEGRATION_CONSUMER, + accesses: ["envelope.threshold"], + }, + "consecutiveFailuresByKind.transient": { + file: INTEGRATION_CONSUMER, + accesses: ["buckets.transient"], + }, + "consecutiveFailuresByKind.reauth_required": { + file: INTEGRATION_CONSUMER, + accesses: ["buckets.reauth_required"], + }, + "consecutiveFailuresByKind.persistent": { + file: INTEGRATION_CONSUMER, + accesses: ["buckets.persistent"], + }, + }); + }); + + it("pairs every admin failing-jobs leaf with the failing-jobs card", () => { + const queueFields = interfaceFields(JOB_FAILURE_SCHEMA, "FailingQueue"); + const route = source(ADMIN_ROUTE); + expect(route).toMatch(/failingJobs:\s*\n?\s*failingJobs === null/); + expect(route).toMatch( + /\{\s*windowHours:\s*JOB_FAILURE_WINDOW_HOURS,\s*queues:\s*failingJobs\s*\}/, + ); + assertJsxHandoff( + join(ROOT, "src/components/admin/system-status-section.tsx"), + "FailingJobsCard", + "failingJobs", + "status.failingJobs", + ); + + const leaves = [ + "failingJobs.windowHours", + ...queueFields.map((field) => `failingJobs.queues.${field}`), + ]; + assertBindings(leaves, { + "failingJobs.windowHours": { + file: ADMIN_CONSUMER, + accesses: ["failingJobs.windowHours"], + }, + "failingJobs.queues.queue": { + file: ADMIN_CONSUMER, + accesses: ["failingJobs.queues", "queue.queue"], + }, + "failingJobs.queues.failures": { + file: ADMIN_CONSUMER, + accesses: ["failingJobs.queues", "queue.failures"], + }, + "failingJobs.queues.lastFailedAt": { + file: ADMIN_CONSUMER, + accesses: ["failingJobs.queues", "queue.lastFailedAt"], + }, + "failingJobs.queues.lastError": { + file: ADMIN_CONSUMER, + accesses: ["failingJobs.queues", "queue.lastError"], + }, + }); + }); + + it("pairs every account-scoped queue-failure leaf with environment settings", () => { + const failureFields = interfaceFields( + JOB_FAILURE_SCHEMA, + "QueueFailureForUser", + ); + assertEnvironmentFailureLineage(ENVIRONMENT_ROUTE); + + const leaves = failureFields.map((field) => `lastFetchFailure.${field}`); + assertBindings(leaves, { + "lastFetchFailure.lastFailedAt": { + file: ENVIRONMENT_CONSUMER, + accesses: ["data.lastFetchFailure.lastFailedAt"], + }, + "lastFetchFailure.failures": { + file: ENVIRONMENT_CONSUMER, + accesses: ["data.lastFetchFailure.failures"], + }, + }); + }); +}); diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts new file mode 100644 index 000000000..a6801e9d7 --- /dev/null +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -0,0 +1,1122 @@ +/** + * Per-envelope response/consumer guard for provider-sync and OCR write results. + * + * The producer shape comes from the shared response interface, each route's + * `apiSuccess` object, and the registered OCR Zod schema. Consumers are pinned + * to the adapter or dialog that owns that envelope. Field names are always + * route-qualified, so a generic `failed` or `source` read elsewhere cannot + * satisfy an unrelated response. + */ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const API_ROOT = join(ROOT, "src/app/api"); +const WRITTEN_OUTCOME_SCHEMA = join(ROOT, "src/lib/outcome/written-outcome.ts"); +const SYNC_CONSUMER = join( + ROOT, + "src/components/settings/integrations/sync-outcome.ts", +); +const SYNC_CARD_ROOT = join(ROOT, "src/components/settings/integrations"); +const OCR_SCHEMA = join(ROOT, "src/lib/openapi/routes/ocr.ts"); +const OCR_CONSUMER = join(ROOT, "src/components/labs/ocr-review-dialog.tsx"); +const OCR_ROUTE = join(ROOT, "src/app/api/labs/ocr/commit/route.ts"); +const LAB_SERIALISER = join(ROOT, "src/lib/labs/serialise.ts"); + +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + source(path), + ts.ScriptTarget.Latest, + true, + path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function propertyName( + name: ts.PropertyName, + file: ts.SourceFile, +): string | null { + if (ts.isComputedPropertyName(name)) return null; + return name.getText(file).replace(/^['"]|['"]$/g, ""); +} + +function expressionAccessPath(expression: ts.Expression): string | null { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) { + const base = expressionAccessPath(expression.expression); + return base === null ? null : `${base}.${expression.name.text}`; + } + if (ts.isElementAccessExpression(expression)) { + const base = expressionAccessPath(expression.expression); + const key = expression.argumentExpression; + if ( + base !== null && + key && + (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) + ) { + return `${base}.${key.text}`; + } + return null; + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return expressionAccessPath(expression.expression); + } + return null; +} + +function executableAccesses(path: string): Set { + const accesses = new Set(); + const file = parse(path); + + function collectBindings( + pattern: ts.ObjectBindingPattern, + base: string, + ): void { + for (const element of pattern.elements) { + const key = + element.propertyName !== undefined + ? propertyName(element.propertyName, file) + : ts.isIdentifier(element.name) + ? element.name.text + : null; + if (key === null) continue; + const access = `${base}.${key}`; + accesses.add(access); + if (ts.isObjectBindingPattern(element.name)) { + collectBindings(element.name, access); + } + } + } + + function visit(node: ts.Node): void { + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const access = expressionAccessPath(node); + if (access !== null) accesses.add(access); + } + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer + ) { + const base = expressionAccessPath(node.initializer); + if (base !== null) collectBindings(node.name, base); + } + ts.forEachChild(node, visit); + } + + visit(file); + return accesses; +} + +function interfaceFields(path: string, interfaceName: string): string[] { + const file = parse(path); + const declarations = new Map( + file.statements.flatMap((statement) => + ts.isInterfaceDeclaration(statement) + ? ([[statement.name.text, statement]] as const) + : [], + ), + ); + + function collect(name: string, seen: Set): string[] { + expect(seen.has(name), `cyclic interface inheritance at ${name}`).toBe( + false, + ); + const declaration = declarations.get(name); + expect( + declaration, + `${name} must remain an interface in ${path}`, + ).toBeDefined(); + const nextSeen = new Set(seen).add(name); + const inherited = (declaration!.heritageClauses ?? []).flatMap((clause) => + clause.types.flatMap((type) => + ts.isIdentifier(type.expression) + ? collect(type.expression.text, nextSeen) + : [], + ), + ); + const own = declaration!.members.flatMap((member) => { + if (!ts.isPropertySignature(member) || member.name === undefined) + return []; + const name = propertyName(member.name, file); + return name === null ? [] : [name]; + }); + return [...new Set([...inherited, ...own])]; + } + + return collect(interfaceName, new Set()); +} + +interface ApiSuccessBranch { + fields: string[]; + resolveCalls: number; +} + +function apiSuccessBranches( + path: string, + writtenOutcomeFields: readonly string[], +): ApiSuccessBranch[] { + const file = parse(path); + const branches: ApiSuccessBranch[] = []; + + function isWrittenOutcomeCall( + expression: ts.Expression, + ): expression is ts.CallExpression { + return ( + ts.isCallExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "resolveSyncOutcome" + ); + } + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" + ) { + const fields: string[] = []; + let resolveCalls = 0; + const payload = node.arguments[0]; + expect( + payload, + `${path} apiSuccess branch must have a payload`, + ).toBeDefined(); + + if (payload && isWrittenOutcomeCall(payload)) { + resolveCalls += 1; + fields.push(...writtenOutcomeFields); + } else if (payload && ts.isObjectLiteralExpression(payload)) { + for (const property of payload.properties) { + if (ts.isSpreadAssignment(property)) { + expect( + isWrittenOutcomeCall(property.expression), + `${path} has an unsupported apiSuccess spread: ${property.getText(file)}`, + ).toBe(true); + if (isWrittenOutcomeCall(property.expression)) { + resolveCalls += 1; + fields.push(...writtenOutcomeFields); + } + continue; + } + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `${path} has unsupported apiSuccess object syntax: ${property.getText(file)}`, + ).toBe(true); + if ( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) + ) { + const name = propertyName(property.name, file); + expect( + name, + `${path} has a computed response field`, + ).not.toBeNull(); + if (resolveCalls > 0 && name !== null) { + expect( + writtenOutcomeFields, + `${path} overrides ${name} after resolveSyncOutcome(...)`, + ).not.toContain(name); + } + if (name !== null) fields.push(name); + } + } + } else if (payload) { + expect( + false, + `${path} has an unsupported apiSuccess payload: ${payload.getText(file)}`, + ).toBe(true); + } + + branches.push({ + fields: [...new Set(fields)], + resolveCalls, + }); + } + ts.forEachChild(node, visit); + } + + visit(file); + expect( + branches.length, + `${path} must still return at least one apiSuccess(...) branch`, + ).toBeGreaterThan(0); + return branches; +} + +function objectLiteralFields( + object: ts.ObjectLiteralExpression, + file: ts.SourceFile, + context: string, +): string[] { + return object.properties.flatMap((property) => { + expect( + ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property), + `${context} contains unsupported object syntax: ${property.getText(file)}`, + ).toBe(true); + if ( + !ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) + ) { + return []; + } + const name = propertyName(property.name, file); + expect(name, `${context} has a computed field`).not.toBeNull(); + return name === null ? [] : [name]; + }); +} + +function apiSuccessObjectBranches(path: string): string[][] { + const file = parse(path); + const branches: string[][] = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" + ) { + const payload = node.arguments[0]; + expect( + payload && ts.isObjectLiteralExpression(payload), + `${path} apiSuccess payload must remain an object literal`, + ).toBe(true); + if (payload && ts.isObjectLiteralExpression(payload)) { + branches.push( + objectLiteralFields(payload, file, `${path} apiSuccess payload`), + ); + } + } + ts.forEachChild(node, visit); + } + visit(file); + expect( + branches.length, + `${path} must still return at least one apiSuccess(...) branch`, + ).toBeGreaterThan(0); + return branches; +} + +function functionReturnObjectBranches( + path: string, + functionName: string, +): string[][] { + const file = parse(path); + const declaration = file.statements.find( + (statement): statement is ts.FunctionDeclaration => + ts.isFunctionDeclaration(statement) && + statement.name?.text === functionName, + ); + expect( + declaration?.body, + `${functionName} must remain a declared function with a body`, + ).toBeDefined(); + const branches: string[][] = []; + function visit(node: ts.Node): void { + if (ts.isReturnStatement(node)) { + expect( + node.expression && ts.isObjectLiteralExpression(node.expression), + `${functionName} return must remain an object literal`, + ).toBe(true); + if (node.expression && ts.isObjectLiteralExpression(node.expression)) { + branches.push( + objectLiteralFields( + node.expression, + file, + `${functionName} return payload`, + ), + ); + } + return; + } + ts.forEachChild(node, visit); + } + visit(declaration!.body!); + expect( + branches.length, + `${functionName} must return a payload`, + ).toBeGreaterThan(0); + return branches; +} + +function pushedObjectBranches(path: string, receiver: string): string[][] { + const file = parse(path); + const branches: string[][] = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === receiver && + node.expression.name.text === "push" + ) { + const payload = node.arguments[0]; + expect( + payload && ts.isObjectLiteralExpression(payload), + `${receiver}.push payload must remain an object literal`, + ).toBe(true); + if (payload && ts.isObjectLiteralExpression(payload)) { + branches.push( + objectLiteralFields(payload, file, `${receiver}.push payload`), + ); + } + } + ts.forEachChild(node, visit); + } + visit(file); + expect( + branches.length, + `${path} must still populate ${receiver}`, + ).toBeGreaterThan(0); + return branches; +} + +function assertPushCallsUse( + path: string, + receiver: string, + callee: string, +): void { + const file = parse(path); + let calls = 0; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === receiver && + node.expression.name.text === "push" + ) { + calls += 1; + const payload = node.arguments[0]; + expect( + payload && + ts.isCallExpression(payload) && + ts.isIdentifier(payload.expression) && + payload.expression.text === callee, + `${receiver}.push must receive ${callee}(...)`, + ).toBe(true); + } + ts.forEachChild(node, visit); + } + visit(file); + expect(calls, `${path} must still populate ${receiver}`).toBeGreaterThan(0); +} + +interface ZodObjectShape { + leaves: string[]; + arrays: string[]; +} + +const TRANSPARENT_ZOD_METHODS = [ + "meta", + "describe", + "nullable", + "optional", + "default", + "catch", + "readonly", + "brand", +] as const; + +const LEAF_ZOD_METHODS = [ + "string", + "number", + "boolean", + "enum", + "literal", + "record", + "datetime", + "int", +] as const; + +function zodObjectShape(path: string, schemaName: string): ZodObjectShape { + const file = parse(path); + const initializers = new Map(); + const arrays = new Set(); + for (const statement of file.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + initializers.set(declaration.name.text, declaration.initializer); + } + } + } + + function collect( + expression: ts.Expression, + prefix: string, + seen: Set, + ): string[] { + let current = expression; + while (ts.isCallExpression(current)) { + const callee = current.expression; + expect( + ts.isPropertyAccessExpression(callee), + `${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`, + ).toBe(true); + if (!ts.isPropertyAccessExpression(callee)) return [prefix]; + const method = callee.name.text; + if (method === "object") { + const object = current.arguments[0]; + expect( + object && ts.isObjectLiteralExpression(object), + `${prefix || schemaName} must use a literal z.object shape`, + ).toBe(true); + return (object as ts.ObjectLiteralExpression).properties.flatMap( + (property) => { + expect( + ts.isPropertyAssignment(property), + `${prefix || schemaName} contains unsupported Zod object syntax: ${property.getText(file)}`, + ).toBe(true); + if (!ts.isPropertyAssignment(property)) return []; + const name = propertyName(property.name, file); + expect( + name, + `${prefix || schemaName} contains a computed property name`, + ).not.toBeNull(); + if (name === null) return []; + const child = prefix ? `${prefix}.${name}` : name; + return collect(property.initializer, child, new Set(seen)); + }, + ); + } + if (method === "array") { + const item = current.arguments[0]; + expect( + item, + `${prefix} array must declare its item schema`, + ).toBeDefined(); + arrays.add(prefix); + return collect(item!, prefix, new Set(seen)); + } + if ( + LEAF_ZOD_METHODS.includes(method as (typeof LEAF_ZOD_METHODS)[number]) + ) { + return [prefix]; + } + expect( + TRANSPARENT_ZOD_METHODS.includes( + method as (typeof TRANSPARENT_ZOD_METHODS)[number], + ), + `${prefix || schemaName} uses unsupported or shape-changing Zod method .${method}()`, + ).toBe(true); + if ( + !TRANSPARENT_ZOD_METHODS.includes( + method as (typeof TRANSPARENT_ZOD_METHODS)[number], + ) + ) { + return [prefix]; + } + current = callee.expression; + } + + if (ts.isIdentifier(current)) { + const referenced = initializers.get(current.text); + if (referenced) { + expect( + seen.has(current.text), + `cyclic Zod schema reference at ${current.text}`, + ).toBe(false); + return collect(referenced, prefix, new Set(seen).add(current.text)); + } + expect( + false, + `${prefix || schemaName} references unknown schema ${current.text}`, + ).toBe(true); + } + return [prefix]; + } + + const root = initializers.get(schemaName); + expect(root, `${schemaName} must remain a declared Zod schema`).toBeDefined(); + return { + leaves: [...new Set(collect(root!, "", new Set([schemaName])))].sort(), + arrays: [...arrays].sort(), + }; +} + +type SyncResponseClass = "resolved" | "resolved-with-full-sync-echo"; + +interface SyncRouteSpec { + id: string; + path: string; + responseClass: SyncResponseClass; + exceptions: Readonly>; + card: string; +} + +const FULL_SYNC_EXCEPTION = + "The boolean intentionally echoes the requested sync mode for programmatic callers; the web card already owns that mode as request state and does not read the echo."; + +const SYNC_ROUTES: readonly SyncRouteSpec[] = [ + { + id: "fitbit", + path: join(ROOT, "src/app/api/fitbit/sync/route.ts"), + responseClass: "resolved-with-full-sync-echo", + card: join(SYNC_CARD_ROOT, "fitbit-card.tsx"), + exceptions: { fullSync: FULL_SYNC_EXCEPTION }, + }, + { + id: "google-health", + path: join(ROOT, "src/app/api/google-health/sync/route.ts"), + responseClass: "resolved-with-full-sync-echo", + card: join(SYNC_CARD_ROOT, "google-health-card.tsx"), + exceptions: { fullSync: FULL_SYNC_EXCEPTION }, + }, + { + id: "nightscout", + path: join(ROOT, "src/app/api/nightscout/sync/route.ts"), + responseClass: "resolved", + card: join(SYNC_CARD_ROOT, "nightscout-card.tsx"), + exceptions: {}, + }, + { + id: "oura", + path: join(ROOT, "src/app/api/oura/sync/route.ts"), + responseClass: "resolved", + card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"), + exceptions: {}, + }, + { + id: "polar", + path: join(ROOT, "src/app/api/polar/sync/route.ts"), + responseClass: "resolved", + card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"), + exceptions: {}, + }, + { + id: "strava", + path: join(ROOT, "src/app/api/strava/sync/route.ts"), + responseClass: "resolved", + card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"), + exceptions: {}, + }, + { + id: "whoop", + path: join(ROOT, "src/app/api/whoop/sync/route.ts"), + responseClass: "resolved-with-full-sync-echo", + card: join(SYNC_CARD_ROOT, "whoop-card.tsx"), + exceptions: { fullSync: FULL_SYNC_EXCEPTION }, + }, + { + id: "withings", + path: join(ROOT, "src/app/api/withings/sync/route.ts"), + responseClass: "resolved-with-full-sync-echo", + card: join(SYNC_CARD_ROOT, "withings-card.tsx"), + exceptions: { fullSync: FULL_SYNC_EXCEPTION }, + }, +] as const; + +const SYNC_CONSUMER_ACCESS: Readonly> = { + imported: ["data.imported"], + failed: ["data.failed"], + outcome: ["data.outcome", "result.outcome"], +}; + +const OCR_ROW_EXCEPTION = + "The web dialog intentionally consumes the collection count only, then invalidates the labs queries. The full committed-row DTO remains on the response for non-web API callers."; +const OCR_SKIP_EXCEPTION = + "The web dialog intentionally consumes the skipped collection count only. Per-row duplicate detail remains on the response for non-web API callers."; + +const OCR_EXCEPTIONS: Readonly> = { + "inserted.id": OCR_ROW_EXCEPTION, + "inserted.biomarkerId": OCR_ROW_EXCEPTION, + "inserted.panel": OCR_ROW_EXCEPTION, + "inserted.analyte": OCR_ROW_EXCEPTION, + "inserted.value": OCR_ROW_EXCEPTION, + "inserted.valueText": OCR_ROW_EXCEPTION, + "inserted.unit": OCR_ROW_EXCEPTION, + "inserted.referenceLow": OCR_ROW_EXCEPTION, + "inserted.referenceHigh": OCR_ROW_EXCEPTION, + "inserted.takenAt": OCR_ROW_EXCEPTION, + "inserted.source": OCR_ROW_EXCEPTION, + "inserted.hasNote": OCR_ROW_EXCEPTION, + "inserted.rangeStatus": OCR_ROW_EXCEPTION, + "inserted.createdAt": OCR_ROW_EXCEPTION, + "inserted.updatedAt": OCR_ROW_EXCEPTION, + "skipped.analyte": OCR_SKIP_EXCEPTION, + "skipped.reason": OCR_SKIP_EXCEPTION, +}; + +const OCR_CONSUMERS: Readonly> = { + outcome: ["result.outcome"], +}; + +const OCR_COLLECTION_CONSUMERS: Readonly> = { + inserted: ["result.inserted.length"], + skipped: ["result.skipped.length"], +}; + +function discoveredSyncRoutes(): Array<{ id: string; path: string }> { + return readdirSync(API_ROOT, { withFileTypes: true }).flatMap((entry) => { + if (!entry.isDirectory()) return []; + const path = join(API_ROOT, entry.name, "sync", "route.ts"); + return existsSync(path) ? [{ id: entry.name, path }] : []; + }); +} + +function directVariableDeclarations(block: ts.Block): ts.VariableDeclaration[] { + return block.statements.flatMap((statement) => + ts.isVariableStatement(statement) + ? [...statement.declarationList.declarations] + : [], + ); +} + +function containsNode(root: ts.Node, target: ts.Node): boolean { + if (root === target) return true; + let found = false; + ts.forEachChild(root, (child) => { + if (!found && containsNode(child, target)) found = true; + }); + return found; +} + +function objectPropertyInitializer( + object: ts.ObjectLiteralExpression, + name: string, + file: ts.SourceFile, +): ts.Expression | null { + const property = object.properties.find( + (candidate) => + ts.isPropertyAssignment(candidate) && + propertyName(candidate.name, file) === name, + ); + return property && ts.isPropertyAssignment(property) + ? property.initializer + : null; +} + +function jsxAttributePath( + node: ts.JsxOpeningElement | ts.JsxSelfClosingElement, + name: string, +): string | null { + const attribute = node.attributes.properties.find( + (candidate) => + ts.isJsxAttribute(candidate) && + ts.isIdentifier(candidate.name) && + candidate.name.text === name, + ); + if ( + !attribute || + !ts.isJsxAttribute(attribute) || + !attribute.initializer || + !ts.isJsxExpression(attribute.initializer) || + !attribute.initializer.expression + ) { + return null; + } + return expressionAccessPath(attribute.initializer.expression); +} + +function assertSyncCardConsumesFetchedResponse( + path: string, + routeId: string, +): void { + const file = parse(path); + const calls: ts.CallExpression[] = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "readSyncOutcome" + ) { + calls.push(node); + } + ts.forEachChild(node, visit); + } + visit(file); + expect(calls, `${path} must call readSyncOutcome exactly once`).toHaveLength( + 1, + ); + const call = calls[0]; + expect( + call.arguments.length === 1 && ts.isIdentifier(call.arguments[0]), + `${path} must pass one fetched response value to readSyncOutcome`, + ).toBe(true); + if (call.arguments.length !== 1 || !ts.isIdentifier(call.arguments[0])) + return; + const responseValue = call.arguments[0].text; + + let block: ts.Node | undefined = call.parent; + while (block && !ts.isBlock(block)) block = block.parent; + expect( + block, + `${path} readSyncOutcome call must live in a block`, + ).toBeDefined(); + if (!block || !ts.isBlock(block)) return; + const declarations = directVariableDeclarations(block); + const jsonDeclaration = declarations.find( + (declaration) => + ts.isIdentifier(declaration.name) && + declaration.name.text === responseValue, + ); + expect( + jsonDeclaration?.initializer, + `${path} must bind the readSyncOutcome argument from response.json()`, + ).toBeDefined(); + if (!jsonDeclaration?.initializer) return; + let jsonInitializer = jsonDeclaration.initializer; + if (ts.isAwaitExpression(jsonInitializer)) + jsonInitializer = jsonInitializer.expression; + expect( + ts.isCallExpression(jsonInitializer) && + ts.isPropertyAccessExpression(jsonInitializer.expression) && + jsonInitializer.expression.name.text === "json" && + ts.isIdentifier(jsonInitializer.expression.expression), + `${path} must bind the adapter input from response.json()`, + ).toBe(true); + if ( + !ts.isCallExpression(jsonInitializer) || + !ts.isPropertyAccessExpression(jsonInitializer.expression) || + !ts.isIdentifier(jsonInitializer.expression.expression) + ) { + return; + } + + const responseName = jsonInitializer.expression.expression.text; + const responseDeclaration = declarations.find( + (declaration) => + ts.isIdentifier(declaration.name) && + declaration.name.text === responseName, + ); + expect( + responseDeclaration?.initializer, + `${path} must bind ${responseName} from apiFetchRaw`, + ).toBeDefined(); + if (!responseDeclaration?.initializer) return; + let responseInitializer = responseDeclaration.initializer; + if (ts.isAwaitExpression(responseInitializer)) + responseInitializer = responseInitializer.expression; + expect( + ts.isCallExpression(responseInitializer) && + ts.isIdentifier(responseInitializer.expression) && + responseInitializer.expression.text === "apiFetchRaw", + `${path} must read the sync response returned by apiFetchRaw`, + ).toBe(true); + if ( + !ts.isCallExpression(responseInitializer) || + responseInitializer.arguments.length === 0 + ) { + return; + } + const endpoint = responseInitializer.arguments[0]; + const expectedEndpoint = path.endsWith("oauth-provider-card.tsx") + ? "`/api/${provider}/sync`" + : `"/api/${routeId}/sync"`; + expect( + endpoint.getText(file), + `${path} must feed the ${routeId} sync response to readSyncOutcome`, + ).toBe(expectedEndpoint); + + const resultDeclarations = declarations.filter( + (declaration) => + declaration.initializer && containsNode(declaration.initializer, call), + ); + expect( + resultDeclarations, + `${path} must bind readSyncOutcome(...) to one result variable`, + ).toHaveLength(1); + const resultDeclaration = resultDeclarations[0]; + expect( + ts.isIdentifier(resultDeclaration.name), + `${path} adapter result must use an identifier`, + ).toBe(true); + if (!ts.isIdentifier(resultDeclaration.name)) return; + const resultName = resultDeclaration.name.text; + expect( + resultDeclaration.initializer && + ts.isConditionalExpression(resultDeclaration.initializer) && + expressionAccessPath(resultDeclaration.initializer.condition) === + `${responseName}.ok` && + resultDeclaration.initializer.whenTrue === call && + resultDeclaration.initializer.whenFalse.kind === + ts.SyntaxKind.NullKeyword, + `${path} result must be the adapter value gated only by the fetched response status`, + ).toBe(true); + + let successWrites = 0; + let renderedStates = 0; + function traceState(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "setSyncResult" && + node.arguments.length === 1 && + ts.isObjectLiteralExpression(node.arguments[0]) + ) { + const state = node.arguments[0]; + const outcome = objectPropertyInitializer(state, "outcome", file); + const message = objectPropertyInitializer(state, "message", file); + const messageUsesResult = + message !== null && + ts.isCallExpression(message) && + ts.isIdentifier(message.expression) && + message.expression.text === "describeSyncOutcome" && + message.arguments.length >= 1 && + ts.isIdentifier(message.arguments[0]) && + message.arguments[0].text === resultName; + if ( + outcome !== null && + expressionAccessPath(outcome) === `${resultName}.outcome` && + messageUsesResult + ) { + successWrites += 1; + } + } + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + ts.isIdentifier(node.tagName) && + node.tagName.text === "WrittenOutcomeLine" && + jsxAttributePath(node, "outcome") === "syncResult.outcome" && + jsxAttributePath(node, "message") === "syncResult.message" + ) { + renderedStates += 1; + } + ts.forEachChild(node, traceState); + } + traceState(file); + expect( + successWrites, + `${path} must store the adapter result outcome and described message exactly once`, + ).toBe(1); + expect( + renderedStates, + `${path} must render the stored outcome and message exactly once`, + ).toBe(1); +} + +function assertOcrOutcomeClassifier(path: string): void { + const file = parse(path); + let branches = 0; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "apiSuccess" + ) { + const payload = node.arguments[0]; + expect( + payload && ts.isObjectLiteralExpression(payload), + "OCR apiSuccess payload must remain an object literal", + ).toBe(true); + if (!payload || !ts.isObjectLiteralExpression(payload)) return; + branches += 1; + const outcome = payload.properties.find( + (property) => + ts.isPropertyAssignment(property) && + propertyName(property.name, file) === "outcome", + ); + expect( + outcome && ts.isPropertyAssignment(outcome), + "OCR response must expose outcome", + ).toBe(true); + if (!outcome || !ts.isPropertyAssignment(outcome)) return; + expect( + ts.isCallExpression(outcome.initializer) && + ts.isIdentifier(outcome.initializer.expression) && + outcome.initializer.expression.text === "classifyWrittenOutcome", + "OCR outcome must come from classifyWrittenOutcome(...)", + ).toBe(true); + if ( + !ts.isCallExpression(outcome.initializer) || + outcome.initializer.arguments.length !== 1 || + !ts.isObjectLiteralExpression(outcome.initializer.arguments[0]) + ) { + return; + } + const counts = outcome.initializer.arguments[0].properties.flatMap( + (property) => { + expect( + ts.isPropertyAssignment(property), + "OCR classifier counts must use explicit object properties", + ).toBe(true); + if (!ts.isPropertyAssignment(property)) return []; + const name = propertyName(property.name, file); + const access = expressionAccessPath(property.initializer); + return name === null || access === null + ? [] + : [[name, access] as const]; + }, + ); + expect(counts.sort((a, b) => a[0].localeCompare(b[0]))).toEqual([ + ["skipped", "skipped.length"], + ["written", "inserted.length"], + ]); + } + ts.forEachChild(node, visit); + } + visit(file); + expect(branches).toBeGreaterThan(0); +} + +describe("written-outcome response consumer guard", () => { + it("pairs every provider sync response leaf with its adapter or exact exception", () => { + const sharedFields = interfaceFields( + WRITTEN_OUTCOME_SCHEMA, + "ResolvedSyncOutcome", + ); + expect( + sharedFields, + "a new shared sync-response field needs an explicit adapter read", + ).toEqual(["imported", "failed", "outcome"]); + + const consumerAccesses = executableAccesses(SYNC_CONSUMER); + expect(Object.keys(SYNC_CONSUMER_ACCESS)).toEqual(sharedFields); + for (const field of sharedFields) { + for (const access of SYNC_CONSUMER_ACCESS[field]) { + expect( + consumerAccesses.has(access), + `${field} no longer reads ${access} in the sync outcome adapter`, + ).toBe(true); + } + } + const discovered = discoveredSyncRoutes().sort((a, b) => + a.id.localeCompare(b.id), + ); + const specified = SYNC_ROUTES.map(({ id, path }) => ({ id, path })).sort( + (a, b) => a.id.localeCompare(b.id), + ); + expect( + specified, + "every provider sync route needs an explicit response classification", + ).toEqual(discovered); + + for (const route of SYNC_ROUTES) { + const branches = apiSuccessBranches(route.path, sharedFields); + for (const [index, branch] of branches.entries()) { + expect( + branch.resolveCalls, + `${route.id} apiSuccess branch ${index + 1} must return resolveSyncOutcome(...) exactly once`, + ).toBe(1); + for (const field of sharedFields) { + expect( + branch.fields, + `${route.id} apiSuccess branch ${index + 1} dropped ${field}`, + ).toContain(field); + } + const extras = branch.fields.filter( + (field) => !sharedFields.includes(field), + ); + const classifiedExtras = + route.responseClass === "resolved-with-full-sync-echo" + ? ["fullSync"] + : []; + expect( + extras.sort(), + `${route.id} branch ${index + 1} must match its response classification`, + ).toEqual(classifiedExtras); + expect( + Object.keys(route.exceptions).sort(), + `${route.id} branch ${index + 1} extras need an exact consumer or documented exception`, + ).toEqual(extras.sort()); + for (const field of extras) { + expect(route.exceptions[field].length).toBeGreaterThan(40); + } + } + assertSyncCardConsumesFetchedResponse(route.card, route.id); + } + }); + + it("pairs every OCR commit response leaf with the dialog or an exact wire-only exception", () => { + const { leaves, arrays } = zodObjectShape(OCR_SCHEMA, "commitResponse"); + const registry = source(OCR_SCHEMA); + expect(registry).toMatch( + /"\/api\/labs\/ocr\/commit":\s*\{[\s\S]*?"200":\s*\{[\s\S]*?schema:\s*dataEnvelope\(\s*commitResponse,\s*"OcrCommitEnvelope"/, + ); + expect(arrays).toEqual(["inserted", "skipped"]); + expect(Object.keys(OCR_COLLECTION_CONSUMERS).sort()).toEqual(arrays); + const responseFields = [ + ...new Set(leaves.map((leaf) => leaf.split(".", 1)[0])), + ].sort(); + for (const [index, fields] of apiSuccessObjectBranches( + OCR_ROUTE, + ).entries()) { + expect( + fields.sort(), + `OCR apiSuccess branch ${index + 1} must expose every registered response field`, + ).toEqual(responseFields); + } + assertOcrOutcomeClassifier(OCR_ROUTE); + + const insertedFields = leaves + .filter((leaf) => leaf.startsWith("inserted.")) + .map((leaf) => leaf.slice("inserted.".length)) + .sort(); + for (const [index, fields] of functionReturnObjectBranches( + LAB_SERIALISER, + "serialiseLabResult", + ).entries()) { + expect( + fields.sort(), + `serialiseLabResult branch ${index + 1} must populate every inserted-row field`, + ).toEqual(insertedFields); + } + assertPushCallsUse(OCR_ROUTE, "inserted", "serialiseLabResult"); + + const skippedFields = leaves + .filter((leaf) => leaf.startsWith("skipped.")) + .map((leaf) => leaf.slice("skipped.".length)) + .sort(); + for (const [index, fields] of pushedObjectBranches( + OCR_ROUTE, + "skipped", + ).entries()) { + expect( + fields.sort(), + `skipped.push branch ${index + 1} must populate every skipped-row field`, + ).toEqual(skippedFields); + } + const accounted = [ + ...Object.keys(OCR_CONSUMERS), + ...Object.keys(OCR_EXCEPTIONS), + ].sort(); + expect( + accounted, + "the OCR response maps must exactly cover the registered producer schema", + ).toEqual(leaves); + + const consumerAccesses = executableAccesses(OCR_CONSUMER); + for (const [leaf, accesses] of Object.entries(OCR_CONSUMERS)) { + for (const access of accesses) { + expect( + consumerAccesses.has(access), + `${leaf} no longer reads ${access} in the OCR dialog`, + ).toBe(true); + } + } + for (const [collection, accesses] of Object.entries( + OCR_COLLECTION_CONSUMERS, + )) { + for (const access of accesses) { + expect( + consumerAccesses.has(access), + `${collection}.length no longer reads ${access} in the OCR dialog`, + ).toBe(true); + } + } + for (const [leaf, reason] of Object.entries(OCR_EXCEPTIONS)) { + expect(leaves, `${leaf} is excepted but no longer returned`).toContain( + leaf, + ); + expect(reason.length).toBeGreaterThan(40); + } + }); +});