// e2e: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build) // 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息) // // 真实卷王模型:应考名单 = 项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER), // 经 /api/project/partner/list 返回;不再是旧代码误读的 surveySetting.whiteLists(题目选项白名单)。 // 覆盖: // 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员) // 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/8.2.34/php.exe'; const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring 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); })();