- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接 - 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey) - 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加 - 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则 - 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致 - 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放 - 测试:新增 test-whitelist.js;run-all.sh 全绿 132
212 lines
10 KiB
JavaScript
212 lines
10 KiB
JavaScript
// e2e: 卷内「答题白名单」自动驱动缺/待考名单(DataService::build)
|
||
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
|
||
//
|
||
// 覆盖:
|
||
// 1) p1 设置了卷内白名单 → 应考名单恰好为白名单成员,缺考仅来自白名单(不含白名单外成员)
|
||
// 2) p2 未设置白名单 → 按现状(全名单)显示,白名单外成员仍在应考/缺考中
|
||
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
||
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
||
const http = require('http');
|
||
const net = require('net');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const PHP_PORT = 18082;
|
||
const MOCK_PORT = 19911;
|
||
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';
|
||
const DEBUG = !!process.env.NY_DEBUG;
|
||
|
||
function resolveDataDir() {
|
||
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
|
||
p = path.resolve(p);
|
||
process.env.NY_DATA = p;
|
||
return p;
|
||
}
|
||
const DATA = resolveDataDir();
|
||
fs.mkdirSync(DATA, { recursive: true });
|
||
const UPLOADS = path.join(DATA, 'uploads');
|
||
|
||
const moved = [], copied = [];
|
||
for (const f of ['ny.db', 'cache/token.json']) {
|
||
const p = path.join(DATA, f);
|
||
if (fs.existsSync(p)) {
|
||
const bak = p + '.nybak';
|
||
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
|
||
}
|
||
}
|
||
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||
const p = path.join(DATA, f);
|
||
if (fs.existsSync(p)) {
|
||
const bak = p + '.nybak';
|
||
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
|
||
}
|
||
}
|
||
const cfgFile = path.join(DATA, 'config.json');
|
||
fs.writeFileSync(cfgFile, JSON.stringify({
|
||
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||
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,
|
||
pass_rate: 60,
|
||
layout: 'default',
|
||
}, null, 2));
|
||
|
||
let mock = null, srv = null, cleanedUp = false;
|
||
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
|
||
function cleanup() {
|
||
if (cleanedUp) return;
|
||
cleanedUp = true;
|
||
killAll();
|
||
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||
const p = path.join(DATA, f);
|
||
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||
}
|
||
for (const [p, bak] of moved.concat(copied)) {
|
||
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
|
||
}
|
||
}
|
||
process.on('exit', cleanup);
|
||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||
|
||
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||
|
||
function waitPort(port, timeoutMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
return new Promise((resolve) => {
|
||
(function attempt() {
|
||
const s = net.connect({ host: '127.0.0.1', port });
|
||
const done = (ok) => { s.destroy(); resolve(ok); };
|
||
s.setTimeout(800, () => done(false));
|
||
s.on('connect', () => done(true));
|
||
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||
})();
|
||
});
|
||
}
|
||
|
||
const cookieJar = [];
|
||
let CSRF = '';
|
||
function baseHeaders() {
|
||
const h = {};
|
||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||
return h;
|
||
}
|
||
function absorb(res) {
|
||
for (const sc of res.headers['set-cookie'] || []) {
|
||
const pair = sc.split(';')[0];
|
||
const name = pair.split('=')[0];
|
||
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||
}
|
||
}
|
||
function request(method, urlPath, headers, bodyBuf) {
|
||
return new Promise((resolve, reject) => {
|
||
const h = Object.assign({}, headers || {});
|
||
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||
const chunks = [];
|
||
res.on('data', c => chunks.push(c));
|
||
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
|
||
});
|
||
r.on('error', reject);
|
||
if (bodyBuf) r.write(bodyBuf);
|
||
r.end();
|
||
});
|
||
}
|
||
const get = (p) => request('GET', p, baseHeaders());
|
||
function formPost(p, fields) {
|
||
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||
}
|
||
function upload(urlPath, fields, file) {
|
||
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||
const parts = [];
|
||
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
||
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
|
||
}
|
||
|
||
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 : '')); }
|
||
}
|
||
|
||
(async () => {
|
||
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||
const phpUp = await waitPort(PHP_PORT, 15000);
|
||
chk('mock 卷王服务已就绪', mockUp);
|
||
chk('PHP 内置服务器已就绪', phpUp);
|
||
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
|
||
|
||
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||
const ac = await get('/api.php?action=auth_check');
|
||
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||
const cg = await get('/api.php?action=config_get');
|
||
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||
}
|
||
}
|
||
chk('已登录并取到 CSRF token', CSRF !== '');
|
||
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
|
||
|
||
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
|
||
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
|
||
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||
});
|
||
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
|
||
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
|
||
|
||
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
|
||
const d1 = await get('/api.php?action=data&projectId=p1');
|
||
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
|
||
const d1d = (d1.body && d1.body.data) || {};
|
||
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
|
||
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
|
||
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
|
||
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
|
||
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
|
||
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
|
||
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
|
||
|
||
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
|
||
const d2 = await get('/api.php?action=data&projectId=p2');
|
||
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
|
||
const d2d = (d2.body && d2.body.data) || {};
|
||
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
|
||
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
|
||
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
|
||
|
||
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
|
||
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
|
||
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
|
||
|
||
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
|
||
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
|
||
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
|
||
|
||
console.log('');
|
||
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||
cleanup();
|
||
process.exit(fail === 0 ? 0 : 1);
|
||
})();
|