import { mkdir, writeFile } from 'node:fs/promises'; import { request } from 'node:https'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, ''); const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin'; const password = process.env.LISGLOSIPS_AUTH_PASSWORD; const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low'; const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`; const timeoutMs = Number(process.env.LISGLOSIPS_CUSTOMERS_TIMEOUT_MS || 30000); const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports'); const customerName = process.env.LISGLOSIPS_CUSTOMER_TEST_NAME || '自动化8.2客户'; const customerDomain = process.env.LISGLOSIPS_CUSTOMER_TEST_DOMAIN || 'codex-82.example.test'; if (!password) { console.error('LISGLOSIPS_AUTH_PASSWORD is required.'); process.exit(2); } function requestApi(path, options = {}) { return new Promise((resolveRequest) => { const startedAt = Date.now(); const url = new URL(path, baseUrl); const method = options.method || 'GET'; const body = options.body === undefined ? undefined : JSON.stringify(options.body); const headers = { Accept: 'application/json', 'User-Agent': 'lisglosips-remote-customers-balance/1.0', ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}), ...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}), }; console.log(`REQ ${method} ${path}`); let settled = false; let req; const hardTimer = setTimeout(() => { req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)); }, timeoutMs); const finish = (result) => { if (settled) { return; } settled = true; clearTimeout(hardTimer); console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`); resolveRequest(result); }; req = request( url, { method, rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1', timeout: timeoutMs, headers, }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { finish({ path, method, ok: true, statusCode: res.statusCode || 0, durationMs: Date.now() - startedAt, contentType: String(res.headers['content-type'] || ''), body: Buffer.concat(chunks).toString('utf8'), }); }); } ); req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)); }); req.on('timeout', () => { req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)); }); req.on('error', (error) => { finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', body: '', error: error.message, }); }); if (body) { req.write(body); } req.end(); }); } function parseJson(result) { try { return JSON.parse(result.body); } catch { return null; } } function decodeCaptcha(imageDataUrl) { const encoded = String(imageDataUrl || '').split(',', 2)[1]; if (!encoded) { return ''; } const svg = Buffer.from(encoded, 'base64').toString('utf8'); return [...svg.matchAll(/]*>([^<]+)<\/text>/g)].map((match) => match[1]).join(''); } async function login(loginUsername, loginPassword) { const captcha = await requestApi('/api/v2/auth/captcha'); const captchaBody = parseJson(captcha); const captchaCode = decodeCaptcha(captchaBody?.imageDataUrl); const result = await requestApi('/api/v2/auth/login', { method: 'POST', body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode, }, }); return { captcha, captchaCode, result, body: parseJson(result) }; } function micros(value) { const raw = String(value); const negative = raw.startsWith('-'); const normalized = negative ? raw.slice(1) : raw; const [integerPart, fractionPart = ''] = normalized.split('.'); const amount = BigInt(integerPart || '0') * 1_000_000n + BigInt(fractionPart.padEnd(6, '0').slice(0, 6) || '0'); return negative ? -amount : amount; } function fixed(value) { const negative = value < 0n; const absolute = negative ? -value : value; const integerPart = absolute / 1_000_000n; const fractionPart = String(absolute % 1_000_000n).padStart(6, '0'); return `${negative ? '-' : ''}${integerPart}.${fractionPart}`; } function addDecimal(left, right) { return fixed(micros(left) + micros(right)); } function check(name, pass, detail) { return { name, pass, detail }; } function statusCheck(name, result, expectedStatus) { return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`); } function reportLine(item) { return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`; } async function findCustomer(accessToken) { const list = await requestApi('/api/v2/customers', { accessToken }); const body = parseJson(list); const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null; return { list, customer }; } async function ensureCustomer(accessToken) { const existing = await findCustomer(accessToken); if (!existing.customer) { const created = await requestApi('/api/v2/customers', { method: 'POST', accessToken, body: { name: customerName, contactName: 'Codex测试联系人', phone: '13800138200', email: 'codex-82@example.test', domain: customerDomain, billingMode: 'PREPAID', creditLimit: '100.000000', minBalance: '5.000000', notes: 'Codex 8.2 automated customer', }, }); return { action: 'created', result: created, customer: parseJson(created) }; } const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, { method: 'PATCH', accessToken, body: { name: customerName, contactName: 'Codex测试联系人', phone: '13800138200', email: 'codex-82@example.test', domain: customerDomain, billingMode: 'PREPAID', creditLimit: '100.000000', minBalance: '5.000000', notes: 'Codex 8.2 automated customer', }, }); return { action: 'updated', result: updated, customer: parseJson(updated) }; } const checks = []; const adminLogin = await login(username, password); checks.push(statusCheck('admin can login', adminLogin.result, 200)); checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`)); const accessToken = adminLogin.body?.accessToken; const lowLogin = await login(lowUsername, lowPassword); checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200)); const lowCustomersList = await requestApi('/api/v2/customers', { accessToken: lowLogin.body?.accessToken }); checks.push(statusCheck('low-privilege user cannot list customers', lowCustomersList, 403)); const customerSetup = await ensureCustomer(accessToken); checks.push(statusCheck(`test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200)); checks.push(check('test customer has expected credit/min balance', customerSetup.customer?.creditLimit === '100.000000' && customerSetup.customer?.minBalance === '5.000000', `creditLimit=${customerSetup.customer?.creditLimit}, minBalance=${customerSetup.customer?.minBalance}`)); const customerId = customerSetup.customer?.id; const customerGet = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}`, { accessToken }); const customerBefore = parseJson(customerGet); checks.push(statusCheck('customer detail can be fetched', customerGet, 200)); checks.push(check('available balance equals balance plus credit limit', customerBefore?.availableBalance === addDecimal(customerBefore?.balance || '0.000000', customerBefore?.creditLimit || '0.000000'), `balance=${customerBefore?.balance}, creditLimit=${customerBefore?.creditLimit}, available=${customerBefore?.availableBalance}`)); const disable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/disable`, { method: 'POST', accessToken }); checks.push(statusCheck('customer can be disabled', disable, 201)); checks.push(check('disabled customer status is DISABLED', parseJson(disable)?.status === 'DISABLED', `status=${parseJson(disable)?.status}`)); const enable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/enable`, { method: 'POST', accessToken }); checks.push(statusCheck('customer can be enabled', enable, 201)); checks.push(check('enabled customer status is ENABLED', parseJson(enable)?.status === 'ENABLED', `status=${parseJson(enable)?.status}`)); const positiveKey = `codex82:positive:${Date.now()}`; const positiveRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, { method: 'POST', accessToken, body: { amount: '10.000000', idempotencyKey: positiveKey, remark: 'Codex 8.2 positive recharge', }, }); const positiveBody = parseJson(positiveRecharge); checks.push(statusCheck('positive customer recharge succeeds', positiveRecharge, 201)); checks.push(check('positive recharge balance delta is +10.000000', positiveBody?.afterBalance === addDecimal(positiveBody?.beforeBalance || '0.000000', '10.000000'), `before=${positiveBody?.beforeBalance}, after=${positiveBody?.afterBalance}`)); const duplicatePositive = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, { method: 'POST', accessToken, body: { amount: '10.000000', idempotencyKey: positiveKey, remark: 'Codex 8.2 positive recharge', }, }); const duplicatePositiveBody = parseJson(duplicatePositive); checks.push(statusCheck('same idempotency key with same body returns cached success', duplicatePositive, 201)); checks.push(check('idempotent duplicate returns same recharge id', duplicatePositiveBody?.id === positiveBody?.id, `first=${positiveBody?.id}, duplicate=${duplicatePositiveBody?.id}`)); const conflictingIdempotency = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, { method: 'POST', accessToken, body: { amount: '11.000000', idempotencyKey: positiveKey, remark: 'Codex 8.2 idempotency conflict', }, }); checks.push(statusCheck('same idempotency key with different body is rejected', conflictingIdempotency, 409)); const negativeKey = `codex82:negative:${Date.now()}`; const negativeRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, { method: 'POST', accessToken, body: { amount: '-3.000000', idempotencyKey: negativeKey, remark: 'Codex 8.2 negative adjustment', }, }); const negativeBody = parseJson(negativeRecharge); checks.push(statusCheck('negative customer recharge deducts balance', negativeRecharge, 201)); checks.push(check('negative recharge balance delta is -3.000000', negativeBody?.afterBalance === addDecimal(negativeBody?.beforeBalance || '0.000000', '-3.000000'), `before=${negativeBody?.beforeBalance}, after=${negativeBody?.afterBalance}, code=${negativeBody?.code || 'n/a'}`)); const rechargeList = await requestApi(`/api/v2/recharges?accountType=CUSTOMER&accountId=${encodeURIComponent(customerId)}&take=20`, { accessToken }); const rechargeListBody = parseJson(rechargeList); checks.push(statusCheck('customer recharge list can be filtered by account', rechargeList, 200)); checks.push(check('recharge list contains positive recharge record', Array.isArray(rechargeListBody?.items) && rechargeListBody.items.some((item) => item.id === positiveBody?.id), `total=${rechargeListBody?.total}`)); const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, { method: 'POST', accessToken: lowLogin.body?.accessToken, body: { amount: '1.000000', idempotencyKey: `codex82:low:${Date.now()}`, remark: 'Should be forbidden', }, }); checks.push(statusCheck('low-privilege user cannot recharge customer', lowRecharge, 403)); const now = new Date(); const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z'); const reportPath = resolve(reportDir, `REMOTE_CUSTOMERS_BALANCE_${stamp}.md`); const failed = checks.filter((item) => !item.pass); const report = [ '# Remote Customers, Recharge, and Balance Test Report', '', `Date: ${now.toISOString()}`, `Base URL: ${baseUrl}`, `Username: ${username}`, `Low-Privilege Username: ${lowUsername}`, `Customer Name: ${customerName}`, `Customer Domain: ${customerDomain}`, '', '| Result | Check | Detail |', '| --- | --- | --- |', ...checks.map(reportLine), '', '## Notes', '', '- Password and token values are intentionally omitted.', '- Negative recharge is asserted as a required business rule: negative amount should deduct customer balance.', '', ].join('\n'); await mkdir(reportDir, { recursive: true }); await writeFile(reportPath, report, 'utf8'); for (const item of checks) { console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`); } console.log(`Report: ${reportPath}`); if (failed.length > 0) { process.exitCode = 1; }