Files
ny-exam-platform/tools/test-config-save.js
T
nxxy335 0fd880624f feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
2026-09-10 17:59:45 +08:00

310 lines
15 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.
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
// 运行:node tools/test-config-save.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18081;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
// 1) 备份 / 准备文件存储模式
const cfgFile = path.join(ROOT, 'data', 'config.json');
const cfgBak = cfgFile + '.bak';
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
const tokenBak = tokenFile + '.bak';
const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
if (hasToken) fs.renameSync(tokenFile, tokenBak);
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: 'mock',
password: 'surveyking',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
include_temp_save: false,
dedupe: true,
name_keywords: '姓名,名字',
full_score: 100,
full_scores: {},
layout: 'default',
}, null, 2));
// 统一收尾钩子:恢复原 config + token + kill 子进程。任何退出路径都会触发
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch(e){}
try { mock && mock.kill('SIGKILL'); } catch(e){}
try {
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
} catch(e) { console.error('cleanup cfg failed:', e.message); }
try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} catch(e) { console.error('cleanup token failed:', e.message); }
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
// 2) 启动 mock 卷王
let mock = null;
let srv = null;
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
function req(method, path, body, headers) {
return new Promise((resolve, reject) => {
// 写操作自动附带 CSRF token(后端对所有写 action 强制校验)
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
const data = body ? JSON.stringify(body) : '';
const h = Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
}, headers || {});
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let j = null;
try { j = JSON.parse(text); } catch (e) {}
// 收集 Set-Cookie
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
h['__followed'] = 1;
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
return resolve(req('GET', res.headers.location, null, h));
}
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
});
});
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
const cookieJar = [];
let CSRF = ''; // 从 config_get 取到的会话 CSRF token,POST 写请求自动附带
function formPost(path, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
});
});
r.on('error', reject);
r.write(body); r.end();
});
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
async function login() {
// 走 login.php 的表单登录拿会话
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
}
(async () => {
// 等服务起来
await delay(2000);
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
await new Promise(r => setTimeout(r, 200));
const http = require('http');
const testCookie = await new Promise((resolve) => {
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
const text = Buffer.concat(c).toString();
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
if (text.indexOf('登录失败') >= 0) {
const m = text.match(/登录失败[^<]+/);
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
} else {
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
}
// 拿同一 cookie 调 auth_check
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
resolve(cookie);
});
}).end();
});
}).end(body);
});
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
// 同步 jar
cookieJar.length = 0;
testCookie.split('; ').forEach(c => cookieJar.push(c));
}
console.log('\n--- 1) 首次登录应成功 ---');
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
// 复查 auth(直接走 Session ID 路径)
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
console.log(' [debug] auth_check:', ac.status, ac.raw);
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
console.log(' [fatal] 未登录,停止测试');
mock.kill('SIGTERM'); srv.kill('SIGTERM');
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
process.exit(1);
}
// 之后用 ping 触发 SurveyKing 登录
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
await delay(100);
chk('token 已缓存', fs.existsSync(tokenFile));
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
const cg1 = await req('GET', '/api.php?action=config_get');
chk('config_get 200', cg1.status === 200);
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
// 取 CSRF token,后续写请求自动附带
CSRF = (cg1.body && cg1.body.data && cg1.body.data.csrf_token) || '';
chk('config_get 返回 csrf_token', CSRF !== '', 'csrf=' + CSRF);
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
const cs1 = await req('POST', '/api.php?action=config_save', {
full_score: 95,
});
chk('config_save 200', cs1.status === 200);
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after));
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
const cs2 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
});
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
? cs2.body.data.config.full_scores : undefined;
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
JSON.stringify(_cs2fs));
// 显式 apply_pid='p1', apply_score=85 → 写入
const cs3 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 85,
});
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
// apply_pid='p1', apply_score=0 → 清除
const cs4 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 0,
});
chk('apply_pid=0 → 清除 p1',
cs4.body && cs4.body.data && cs4.body.data.config
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
const cs5 = await req('POST', '/api.php?action=config_save', {
password: 'temp-wrong-password',
});
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
await delay(200);
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
const cs5b = await req('POST', '/api.php?action=config_save', {
password: 'surveyking',
});
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
await delay(200);
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
console.log('\n--- 6) config_get 永远不带明文密码 ---');
const cg2 = await req('GET', '/api.php?action=config_get');
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
const cs6 = await req('POST', '/api.php?action=config_save', {
username: 'mock',
password: 'wrong-password',
});
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
// 清理 + 退出(统一走 cleanup 钩子,保证 config/token 一定被还原)
cleanup();
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
})().catch(e => { console.error('FATAL', e); cleanup(); process.exit(2); });