flnxi-Wu/scripts/customer-acceptance-smoke.mjs
2026-06-01 17:00:05 +08:00

349 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <path> 读取 .env.smoke,默认 .env.smoke
--email <email> 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL
--password <password> 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD
--write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入
--no-expect-admin 登录账号不是最高权限/管理员时使用
--timeout-ms <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)
})