#!/usr/bin/env node // FLNXI customer acceptance smoke for a deployed app. import { readFile } from 'node:fs/promises' const DEFAULT_TIMEOUT_MS = 15000 function parseArgs(argv) { const args = { baseUrl: '', envFile: '.env.smoke', email: '', password: '', write: false, expectAdmin: true, timeoutMs: DEFAULT_TIMEOUT_MS, } for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] switch (arg) { case '--base-url': args.baseUrl = argv[++index] || '' break case '--env-file': args.envFile = argv[++index] || '' break case '--email': args.email = argv[++index] || '' break case '--password': args.password = argv[++index] || '' break case '--write': args.write = true break case '--no-expect-admin': args.expectAdmin = false break case '--timeout-ms': args.timeoutMs = Number(argv[++index] || DEFAULT_TIMEOUT_MS) break case '-h': case '--help': printHelp() process.exit(0) break default: throw new Error(`未知参数:${arg}`) } } return args } function printHelp() { console.log(`FLNXI 客户业务验收 smoke 用法: node scripts/customer-acceptance-smoke.mjs --base-url http://127.0.0.1:4320 [选项] 常用选项: --env-file 读取 .env.smoke,默认 .env.smoke --email 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL --password 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD --write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入 --no-expect-admin 登录账号不是最高权限/管理员时使用 --timeout-ms 单个请求超时,默认 15000 .env.smoke 支持: SMOKE_BASE_URL=http://127.0.0.1:4320 SMOKE_ADMIN_EMAIL=admin@example.com SMOKE_ADMIN_PASSWORD=replace-with-smoke-password `) } async function loadEnvFile(path) { if (!path) return {} try { const content = await readFile(path, 'utf8') const values = {} for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim() if (!line || line.startsWith('#') || !line.includes('=')) continue const [key, ...rest] = line.split('=') let value = rest.join('=').trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } values[key.trim()] = value } return values } catch (error) { if (error?.code === 'ENOENT') return {} throw error } } function normalizeBaseUrl(baseUrl) { const normalized = (baseUrl || '').trim().replace(/\/+$/, '') if (!normalized) { throw new Error('缺少 --base-url 或 SMOKE_BASE_URL') } return normalized } function splitSetCookie(headerValue) { if (!headerValue) return [] return headerValue.split(/,(?=\s*[^;,=\s]+=[^;]+)/g).map((value) => value.trim()).filter(Boolean) } class CookieJar { constructor() { this.cookies = new Map() } apply(response) { const headers = response.headers const setCookies = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : splitSetCookie(headers.get('set-cookie')) for (const setCookie of setCookies) { const pair = setCookie.split(';')[0] const separator = pair.indexOf('=') if (separator <= 0) continue this.cookies.set(pair.slice(0, separator), pair.slice(separator + 1)) } } header() { return Array.from(this.cookies.entries()).map(([key, value]) => `${key}=${value}`).join('; ') } } function withTimeout(timeoutMs) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) return { controller, timeout } } async function request(baseUrl, path, options, jar, timeoutMs) { const { controller, timeout } = withTimeout(timeoutMs) try { const headers = new Headers(options?.headers || {}) if (jar && jar.header()) { headers.set('cookie', jar.header()) } const response = await fetch(`${baseUrl}${path}`, { ...options, headers, redirect: options?.redirect || 'manual', signal: controller.signal, }) if (jar) jar.apply(response) return response } finally { clearTimeout(timeout) } } async function readJsonSafe(response) { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return { raw: text.slice(0, 500) } } } function assertStatus(label, response, expected) { const ok = Array.isArray(expected) ? expected.includes(response.status) : response.status === expected if (!ok) { throw new Error(`${label} 状态码不符合预期:实际 ${response.status},预期 ${Array.isArray(expected) ? expected.join('/') : expected}`) } } function logOk(message) { console.log(`[OK] ${message}`) } async function checkPublic(baseUrl, timeoutMs) { const health = await request(baseUrl, '/api/health', { method: 'GET' }, null, timeoutMs) assertStatus('/api/health', health, 200) const healthJson = await readJsonSafe(health) if (healthJson?.status !== 'ok') { throw new Error('/api/health 没有返回 status=ok') } logOk('/api/health 正常') const init = await request(baseUrl, '/api/system/init', { method: 'GET' }, null, timeoutMs) assertStatus('/api/system/init', init, 200) await readJsonSafe(init) logOk('/api/system/init 可读取') const login = await request(baseUrl, '/login', { method: 'GET' }, null, timeoutMs) assertStatus('/login', login, 200) logOk('/login 可打开') const blockedAdmin = await request(baseUrl, '/api/admin/users', { method: 'GET' }, null, timeoutMs) assertStatus('未登录访问 /api/admin/users', blockedAdmin, [401, 403]) logOk('未登录访问后台用户接口已被拦截') } async function login(baseUrl, email, password, timeoutMs) { if (!email || !password) { throw new Error('需要 --email/--password 或 SMOKE_ADMIN_EMAIL/SMOKE_ADMIN_PASSWORD 才能执行登录验收') } const jar = new CookieJar() const csrfResponse = await request(baseUrl, '/api/auth/csrf', { method: 'GET' }, jar, timeoutMs) assertStatus('/api/auth/csrf', csrfResponse, 200) const csrf = await readJsonSafe(csrfResponse) if (!csrf?.csrfToken) { throw new Error('未取得 NextAuth CSRF token') } const body = new URLSearchParams({ csrfToken: csrf.csrfToken, email, password, redirect: 'false', json: 'true', }) const callback = await request( baseUrl, '/api/auth/callback/credentials?json=true', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, }, jar, timeoutMs ) assertStatus('账号登录', callback, [200, 302]) const session = await request(baseUrl, '/api/auth/session', { method: 'GET' }, jar, timeoutMs) assertStatus('/api/auth/session', session, 200) const sessionJson = await readJsonSafe(session) if (!sessionJson?.user?.email) { throw new Error('登录后没有拿到有效 session') } logOk('账号登录成功') return jar } async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) { const defaultPassword = await request(baseUrl, '/api/user/check-default-password', { method: 'GET' }, jar, timeoutMs) assertStatus('/api/user/check-default-password', defaultPassword, 200) const defaultPasswordJson = await readJsonSafe(defaultPassword) logOk('默认密码检查接口正常') if (defaultPasswordJson?.mustChangePassword || defaultPasswordJson?.isDefaultPassword) { const templatesWhileLocked = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs) assertStatus('首次改密前访问 /api/upload-templates', templatesWhileLocked, 403) const adminUsersWhileLocked = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs) assertStatus('首次改密前访问 /api/admin/users', adminUsersWhileLocked, 403) logOk('首次改密前业务接口已被拦截') return { passwordChangeRequired: true } } const templates = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs) assertStatus('/api/upload-templates', templates, 200) logOk('普通上传模板接口正常') const builtinTemplates = await request(baseUrl, '/api/templates', { method: 'GET' }, jar, timeoutMs) assertStatus('/api/templates', builtinTemplates, 200) logOk('内置模板接口正常') const adminUsers = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs) if (expectAdmin) { assertStatus('/api/admin/users', adminUsers, 200) logOk('后台用户接口正常') } else { assertStatus('/api/admin/users', adminUsers, [200, 403]) logOk('后台用户接口权限表现符合当前账号类型') } return { passwordChangeRequired: false } } async function writeUploadSmoke(baseUrl, jar, timeoutMs) { const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14) const png1x1 = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64' ) const form = new FormData() form.set('productModel', `FLNXI客户验收-${stamp}`) form.set('categoryName', '客户验收') form.set('sceneName', '客户验收') form.set('materialType', '3DMAX') form.set('renderer', 'CR') form.set('previewImage', new Blob([png1x1], { type: 'image/png' }), `flnxi-smoke-${stamp}.png`) form.set('zipFile', new Blob([Buffer.from('FLNXI customer smoke file\n')], { type: 'application/zip' }), `flnxi-smoke-${stamp}.zip`) const response = await request( baseUrl, '/api/materials/upload', { method: 'POST', body: form }, jar, timeoutMs ) const data = await readJsonSafe(response) if (response.status !== 201) { const reason = data?.error || data?.message || data?.raw || '无错误详情' throw new Error(`真实小素材上传失败:HTTP ${response.status},${reason}`) } const materialId = data?.material?.id || data?.material?.materialId || data?.material?.code || '已创建' logOk(`真实小素材上传成功:${materialId}`) } async function main() { const args = parseArgs(process.argv.slice(2)) const env = await loadEnvFile(args.envFile) const baseUrl = normalizeBaseUrl(args.baseUrl || process.env.SMOKE_BASE_URL || env.SMOKE_BASE_URL || env.APP_PUBLIC_URL || env.NEXTAUTH_URL) const email = args.email || process.env.SMOKE_ADMIN_EMAIL || env.SMOKE_ADMIN_EMAIL const password = args.password || process.env.SMOKE_ADMIN_PASSWORD || env.SMOKE_ADMIN_PASSWORD console.log(`[INFO] 验收地址:${baseUrl}`) await checkPublic(baseUrl, args.timeoutMs) if (email || password || args.write) { const jar = await login(baseUrl, email, password, args.timeoutMs) const authCheck = await checkAuthenticated(baseUrl, jar, args.expectAdmin, args.timeoutMs) if (args.write) { if (authCheck.passwordChangeRequired) { throw new Error('当前账号仍需首次改密,不能执行写入验收;请先完成改密后再使用 --write') } await writeUploadSmoke(baseUrl, jar, args.timeoutMs) } } else { console.log('[INFO] 未提供验收账号,已跳过登录、后台和上传验收。') } console.log('[OK] 客户业务验收 smoke 完成') } main().catch((error) => { console.error(`[ERROR] ${error?.message || error}`) process.exit(1) })