公网 /account/ 下绝对路径 /api/admin 会被 OpenIM 接走,无条件 res.json() 把非 JSON 响应打成英文异常。按页面路径拼接前缀,并先看状态再解析。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
/**
|
|
* 员工管理页热修检查(零 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('<html>oops</html>', {
|
|
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);
|
|
});
|