/** * uat/cli/lib/users-file.ts — The `uat-users.json` credentials sidecar. * * `/uat provision` writes one user per role (plus the UAT tenant identity) here; * the API + UI runners read it to log in as each role. The file holds PLAINTEXT * passwords for throwaway accounts, so it is enforced-gitignored: every writer * calls `ensureUatGitignore()` which appends the ignore block to the project's * `.gitignore` when absent (idempotent — runs are heavy too and never committed). */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { z } from 'zod'; export const UatUserSchema = z.object({ role: z.string().min(1), email: z.string().min(3), password: z.string().min(1), userId: z.string().optional(), /** Last successful login-verify timestamp (ISO). */ verifiedAt: z.string().optional(), }); export type UatUser = z.infer; export const UatUsersFileSchema = z.object({ version: z.literal('1'), application: z.string().min(1), apiUrl: z.string().min(1), tenant: z.object({ name: z.string().min(1), slug: z.string().min(1), id: z.string().optional(), }), createdAt: z.string().min(1), users: z.array(UatUserSchema).default([]), }); export type UatUsersFile = z.infer; /** Relative path (POSIX) of the users file for an application. */ export function usersFileRelPath(application: string): string { return `.application-test/uat/${application}/uat-users.json`; } /** Load + validate a users file; null when absent or invalid (callers re-provision). */ export function loadUsersFile(absPath: string): UatUsersFile | null { try { if (!existsSync(absPath)) return null; const parsed = UatUsersFileSchema.safeParse(JSON.parse(readFileSync(absPath, 'utf-8'))); return parsed.success ? parsed.data : null; } catch { return null; } } export function saveUsersFile(absPath: string, file: UatUsersFile): void { mkdirSync(dirname(absPath), { recursive: true }); writeFileSync(absPath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8'); } /** Roles (anonymous excluded) that have NO credential entry in the file. PURE. */ export function missingRoles(file: UatUsersFile | null, roles: readonly string[]): string[] { const have = new Set((file?.users ?? []).map((u) => u.role)); return roles.filter((r) => r !== 'anonymous' && !have.has(r)); } const GITIGNORE_HEADER = '# SmartStack UAT artifacts (credentials + heavy run output) — never commit'; const GITIGNORE_LINES = [ GITIGNORE_HEADER, '.application-test/uat/**/uat-users.json', '.application-test/uat/**/runs/', ]; /** Append the UAT ignore block to `.gitignore` when absent. Returns true when modified. */ export function ensureUatGitignore(projectRoot: string): boolean { const file = join(projectRoot, '.gitignore'); const current = existsSync(file) ? readFileSync(file, 'utf-8') : ''; if (current.includes(GITIGNORE_LINES[1])) return false; const block = `${GITIGNORE_LINES.join('\n')}\n`; const next = current.length === 0 ? block : `${current.replace(/\n*$/, '\n\n')}${block}`; writeFileSync(file, next, 'utf-8'); return true; }