flnxi-Wu/scripts/customer-acceptance-smoke.mjs
2026-06-01 15:26:33 +08:00

332 lines
10 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 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 defaultPassword = await request(baseUrl, '/api/user/check-default-password', { method: 'GET' }, jar, timeoutMs)
assertStatus('/api/user/check-default-password', defaultPassword, 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('后台用户接口权限表现符合当前账号类型')
}
}
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)
await checkAuthenticated(baseUrl, jar, args.expectAdmin, args.timeoutMs)
if (args.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)
})