/** * 员工管理页热修检查(零 npm 依赖) * * 覆盖: * - /account/ 与直连根路径下的接口地址 * - 页面所有管理操作都走同一套请求封装 * - 正确口令能加载列表;错误口令显示中文 * - 添加合法员工返回 JSON * - 上游非 JSON / 空响应时页面有中文兜底 * * 运行:node test-admin-page.js * 不打印口令或员工密码。 */ 'use strict'; const assert = require('assert'); const fs = require('fs'); const http = require('http'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const { adminApiUrl, httpStatusHint, toUserMessage, readAdminJson } = require('./admin-client'); const DIR = __dirname; let passed = 0; const failures = []; function ok(name) { passed += 1; console.log(' pass ' + name); } function fail(name, err) { failures.push(name + ': ' + (err && err.message ? err.message : String(err))); console.log(' FAIL ' + name); } async function check(name, fn) { try { await fn(); ok(name); } catch (e) { fail(name, e); } } function sliceHelpers(src) { const start = src.indexOf('function adminApiUrl'); if (start < 0) throw new Error('找不到 adminApiUrl'); let end = src.indexOf('async function api('); if (end < 0) end = src.indexOf('module.exports'); if (end < 0) throw new Error('找不到辅助函数结束位置'); return src.slice(start, end).replace(/\s+/g, ' ').trim(); } function hasCjk(s) { return /[\u4e00-\u9fff]/.test(String(s || '')); } function hasEnglishTechError(s) { return /unexpected end of json|failed to execute ['"]json['"]|unexpected token/i.test(String(s || '')); } function listen(server) { return new Promise((resolve, reject) => { server.listen(0, '127.0.0.1', () => resolve(server.address().port)); server.on('error', reject); }); } function waitHealth(url, timeoutMs) { const start = Date.now(); return new Promise((resolve, reject) => { const tick = () => { fetch(url).then((r) => { if (r.ok) return resolve(); throw new Error(String(r.status)); }).catch((e) => { if (Date.now() - start > timeoutMs) return reject(new Error('服务未就绪')); setTimeout(tick, 80); }); }; tick(); }); } async function main() { console.log('account-service admin page checks'); const html = fs.readFileSync(path.join(DIR, 'admin.html'), 'utf8'); const clientSrc = fs.readFileSync(path.join(DIR, 'admin-client.js'), 'utf8'); await check('页面不再无条件 res.json()', () => { assert.ok(!/\.json\s*\(/.test(html), 'admin.html 仍调用 res.json()'); }); await check('所有管理操作走 api() 封装', () => { const ops = [ '/api/admin/employees', '/api/admin/import', '/api/admin/enable', '/api/admin/disable', '/api/admin/reset_password', '/api/admin/delete', ]; for (const op of ops) { assert.ok(html.includes("api('" + op) || html.includes('api(\'' + op) || html.includes('/api/admin/\' +'), '缺少 ' + op); } assert.ok(html.includes("api('/api/admin/' + (enable ? 'enable' : 'disable')")); assert.ok((html.match(/await api\(/g) || []).length >= 7); }); await check('html 与 admin-client.js 辅助函数一致', () => { const a = sliceHelpers(html); const b = sliceHelpers(clientSrc); assert.ok(a.length > 80 && b.length > 80); assert.strictEqual(a, b, 'admin.html 内联辅助函数与 admin-client.js 不一致'); }); await check('直连根路径与 /account/ 前缀地址', () => { const route = '/api/admin/employees'; assert.strictEqual(adminApiUrl(route, '/'), '/api/admin/employees'); assert.strictEqual(adminApiUrl(route, '/admin'), '/api/admin/employees'); assert.strictEqual(adminApiUrl(route, '/account/'), '/account/api/admin/employees'); assert.strictEqual(adminApiUrl(route, '/account'), '/account/api/admin/employees'); assert.strictEqual(adminApiUrl(route, '/account/admin'), '/account/api/admin/employees'); assert.strictEqual(adminApiUrl(route, '/account/admin/'), '/account/api/admin/employees'); assert.strictEqual(adminApiUrl('/api/admin/import', '/account/'), '/account/api/admin/import'); assert.strictEqual(adminApiUrl('/api/admin/enable', '/account/'), '/account/api/admin/enable'); assert.strictEqual(adminApiUrl('/api/admin/disable', '/account/'), '/account/api/admin/disable'); assert.strictEqual(adminApiUrl('/api/admin/reset_password', '/account/'), '/account/api/admin/reset_password'); assert.strictEqual(adminApiUrl('/api/admin/delete', '/account/'), '/account/api/admin/delete'); }); await check('错误口令 / 404 / 空响应 / 非 JSON 均为中文兜底', async () => { const cases = [ { name: '401 json', res: new Response(JSON.stringify({ code: 1, msg: '管理口令错误或未提供' }), { status: 401, headers: { 'content-type': 'application/json; charset=utf-8' }, }), expect: '管理口令错误或未提供', }, { name: '404 text', res: new Response('404 page not found', { status: 404, headers: { 'content-type': 'text/plain' }, }), expect: httpStatusHint(404), }, { name: 'empty 404', res: new Response('', { status: 404, headers: { 'content-type': 'text/plain' } }), expect: httpStatusHint(404), }, { name: '500 html', res: new Response('oops', { status: 500, headers: { 'content-type': 'text/html' }, }), expect: httpStatusHint(500), }, { name: 'broken json', res: new Response('{', { status: 200, headers: { 'content-type': 'application/json' } }), expect: '服务器返回了无法识别的内容', }, ]; for (const c of cases) { let msg = ''; try { await readAdminJson(c.res); throw new Error(c.name + ' 应当失败'); } catch (e) { msg = toUserMessage(e); } assert.ok(hasCjk(msg), c.name + ' 不是中文: ' + msg); assert.ok(!hasEnglishTechError(msg), c.name + ' 泄漏英文: ' + msg); if (c.expect) assert.strictEqual(msg, c.expect, c.name); } const english = toUserMessage(new Error("Failed to execute 'json' on 'Response': Unexpected end of JSON input")); assert.strictEqual(english, '服务器返回了无法识别的内容'); assert.ok(hasCjk(english)); }); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acct-admin-')); const dataFile = path.join(tmpDir, 'employees.json'); fs.writeFileSync(dataFile, '{}'); const mockIm = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); let buf = ''; req.on('data', (c) => { buf += c; }); req.on('end', () => { if ((req.url || '').includes('/auth/get_admin_token')) { return res.end(JSON.stringify({ errCode: 0, data: { token: 'mock', expireTimeSeconds: 3600 } })); } if ((req.url || '').includes('/user/user_register')) { return res.end(JSON.stringify({ errCode: 0 })); } if ((req.url || '').includes('/user/get_users_info')) { return res.end(JSON.stringify({ errCode: 0, data: { usersInfo: [{ userID: 't10001' }] } })); } res.statusCode = 404; res.end(JSON.stringify({ errCode: 1 })); }); }); const imPort = await listen(mockIm); const acctPort = 18000 + Math.floor(Math.random() * 2000); const adminToken = 'unit-test-admin'; const child = spawn(process.execPath, ['server.js'], { cwd: DIR, env: { ...process.env, PORT: String(acctPort), DATA_FILE: dataFile, ADMIN_TOKEN: adminToken, OPENIM_API_URL: 'http://127.0.0.1:' + imPort, OPENIM_SECRET: 'mock', LIVEKIT_API_KEY: '', LIVEKIT_API_SECRET: '', }, stdio: ['ignore', 'pipe', 'pipe'], }); const secretRe = new RegExp(adminToken + '|initpass123', 'g'); const scrub = (s) => String(s || '').replace(secretRe, '[redacted]'); child.stdout.on('data', (d) => { const t = scrub(d); if (t.trim()) console.log(' [server] ' + t.trim()); }); child.stderr.on('data', (d) => { const t = scrub(d); if (t.trim()) console.log(' [server-err] ' + t.trim()); }); const stop = () => { try { child.kill('SIGTERM'); } catch { /* ignore */ } try { mockIm.close(); } catch { /* ignore */ } try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } }; try { await waitHealth('http://127.0.0.1:' + acctPort + '/api/health', 8000); await check('正确口令能加载列表', async () => { const res = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/employees', { headers: { 'admin-token': adminToken }, }); const data = await readAdminJson(res); assert.ok(data && typeof data.total === 'number'); assert.ok(Array.isArray(data.employees)); }); await check('错误口令显示中文', async () => { const res = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/employees', { headers: { 'admin-token': 'wrong-token' }, }); let msg = ''; try { await readAdminJson(res); } catch (e) { msg = toUserMessage(e); } assert.ok(msg); assert.ok(hasCjk(msg), '不是中文: ' + msg); assert.ok(!hasEnglishTechError(msg)); assert.ok(/口令|未提供|没有权限/.test(msg), msg); }); await check('添加合法员工返回 JSON', async () => { const res = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/employees', { method: 'POST', headers: { 'Content-Type': 'application/json', 'admin-token': adminToken }, body: JSON.stringify({ staffNo: 't10001', name: '测试员', password: 'initpass123' }), }); const ctype = String(res.headers.get('content-type') || ''); assert.ok(ctype.indexOf('json') !== -1, 'content-type=' + ctype); const data = await readAdminJson(res); assert.strictEqual(data.staffNo, 't10001'); assert.strictEqual(data.name, '测试员'); assert.strictEqual(data.status, 'active'); assert.ok(!JSON.stringify(data).includes('initpass123')); assert.ok(!('hash' in data) && !('salt' in data) && !('password' in data)); }); await check('启停/重置/删除走同一解析且成功返回 JSON', async () => { const tokenHeader = { 'Content-Type': 'application/json', 'admin-token': adminToken }; const disableRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/disable', { method: 'POST', headers: tokenHeader, body: JSON.stringify({ staffNo: 't10001' }), }); const disabled = await readAdminJson(disableRes); assert.strictEqual(disabled.status, 'disabled'); const enableRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/enable', { method: 'POST', headers: tokenHeader, body: JSON.stringify({ staffNo: 't10001' }), }); const enabled = await readAdminJson(enableRes); assert.strictEqual(enabled.status, 'active'); const resetRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/reset_password', { method: 'POST', headers: tokenHeader, body: JSON.stringify({ staffNo: 't10001', password: 'initpass123' }), }); const reset = await readAdminJson(resetRes); assert.strictEqual(reset.staffNo, 't10001'); const delRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/delete', { method: 'POST', headers: tokenHeader, body: JSON.stringify({ staffNo: 't10001' }), }); const del = await readAdminJson(delRes); assert.strictEqual(del.deleted, true); }); await check('上游非 JSON 时页面中文兜底(模拟公网误入 OpenIM)', async () => { const upstream = http.createServer((req, res) => { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('404 page not found'); }); const upPort = await listen(upstream); try { const res = await fetch('http://127.0.0.1:' + upPort + '/api/admin/employees'); let msg = ''; try { await readAdminJson(res); } catch (e) { msg = toUserMessage(e); } assert.ok(hasCjk(msg)); assert.ok(!hasEnglishTechError(msg)); assert.strictEqual(msg, '接口地址不正确,请从管理页重新打开'); } finally { upstream.close(); } }); } finally { stop(); await new Promise((r) => setTimeout(r, 150)); } console.log(''); if (failures.length) { console.log(failures.length + ' failed, ' + passed + ' passed'); for (const f of failures) console.log(' - ' + f); process.exit(1); } console.log('all ' + passed + ' passed'); } main().catch((e) => { console.error('test crashed (no secrets printed)'); console.error(e && e.message ? e.message : 'unknown error'); process.exit(1); });