/** * uat-api/validate.ts — Spec parse + resolution of plan, credentials and API URL * for the API-axis runner. The users file is REQUIRED for authenticated roles * (anonymous runs without it), so a missing file routes to /uat provision. */ import path from 'node:path'; import { existsSync, statSync } from 'node:fs'; import { findSmartStackStructure } from '../../../lib/detector.js'; import { resolveApiUrl } from '../lib/app-config.js'; import { locatePlan, loadPlan } from '../lib/plan-io.js'; import { loadUsersFile, usersFileRelPath, type UatUsersFile } from '../lib/users-file.js'; import { defaultRunId, runDirRelPath } from '../lib/run-id.js'; import type { PlanTest } from '../lib/plantest-schema.js'; import { UatApiInputSchema, type UatApiInput } from './types.js'; export interface ApiRunContext { spec: UatApiInput; projectRoot: string; application: string; plan: PlanTest; planRelPath: string; users: UatUsersFile | null; apiUrl: string; apiUrlSource: string; /** Artifact dir, relative to projectRoot (POSIX). */ runDirRel: string; runId: string; } export interface ApiValidation { valid: boolean; context: ApiRunContext | null; errors: string[]; warnings: string[]; } const fail = (errors: string[], warnings: string[] = []): ApiValidation => ({ valid: false, context: null, errors, warnings, }); export async function validate(raw: unknown): Promise { const parsed = UatApiInputSchema.safeParse(raw); if (!parsed.success) { return fail(parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)); } const spec = parsed.data; const warnings: string[] = []; const projectRoot = path.resolve(spec.projectPath); if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) { return fail([`projectPath is not a directory: ${projectRoot}`]); } const located = locatePlan({ projectRoot, planFile: spec.planFile, path: spec.path }); if (!located.ok) return fail([located.error]); const loaded = loadPlan(located.location.absPath); if (!loaded.ok) return fail(loaded.errors); warnings.push(...loaded.violations); const plan = loaded.plan; const application = plan.meta.application; if (plan.endpoints.length === 0) { return fail([ `The plan carries no endpoints[] (generated with includeApi:false?). Regenerate with /uat plan before running the API axis.`, ]); } const usersAbs = spec.usersFile ? path.resolve(projectRoot, spec.usersFile) : path.join(projectRoot, usersFileRelPath(application)); const users = loadUsersFile(usersAbs); const authRoles = plan.roles.filter((r) => r !== 'anonymous'); const selected = (spec.roles && spec.roles.length > 0 ? spec.roles : plan.roles).filter((r) => plan.roles.includes(r), ); const needsUsers = selected.some((r) => r !== 'anonymous'); if (needsUsers && !users) { return fail([ `No usable uat-users.json (looked at ${usersAbs}). Run \`/uat provision\` first — the API axis logs in as each of: ${authRoles.join(', ')}.`, ]); } let apiUrl: string; let apiUrlSource: string; if (spec.apiUrl) { apiUrl = spec.apiUrl.replace(/\/+$/, ''); apiUrlSource = 'spec'; } else if (users?.apiUrl) { apiUrl = users.apiUrl.replace(/\/+$/, ''); apiUrlSource = 'uat-users.json'; } else { const structure = await findSmartStackStructure(projectRoot); const apiDir = structure.api ?? structure.apiExtensions ?? structure.apiCore ?? null; const resolved = resolveApiUrl(apiDir ?? projectRoot); apiUrl = resolved.value; apiUrlSource = resolved.source; } const runId = spec.runId ?? defaultRunId(); const runDirRel = (spec.outDir ?? runDirRelPath(application, runId)).replace(/\/+$/, ''); return { valid: true, context: { spec, projectRoot, application, plan, planRelPath: located.location.relPath, users, apiUrl, apiUrlSource, runDirRel, runId, }, errors: [], warnings, }; }