360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
#!/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: '',
|
||
expectVersion: '',
|
||
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 '--expect-version':
|
||
args.expectVersion = 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
|
||
--expect-version <ver> 期望 /api/health 返回的版本,也可用 SMOKE_EXPECT_VERSION
|
||
--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
|
||
SMOKE_EXPECT_VERSION=1.0.1
|
||
`)
|
||
}
|
||
|
||
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, expectVersion) {
|
||
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')
|
||
}
|
||
const actualVersion = String(healthJson?.version || '').trim()
|
||
if (expectVersion && actualVersion !== expectVersion) {
|
||
throw new Error(`/api/health 版本不符合预期:实际 ${actualVersion || '未返回'},预期 ${expectVersion}`)
|
||
}
|
||
logOk(actualVersion ? `/api/health 正常,版本 ${actualVersion}` : '/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
|
||
const expectVersion = args.expectVersion || process.env.SMOKE_EXPECT_VERSION || env.SMOKE_EXPECT_VERSION || ''
|
||
|
||
console.log(`[INFO] 验收地址:${baseUrl}`)
|
||
await checkPublic(baseUrl, args.timeoutMs, expectVersion)
|
||
|
||
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)
|
||
})
|