// 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/8.2.34/php.exe'; const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring // 1) 备份 / 准备文件存储模式 // 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持): // 测试统一把 NY_DATA 指向临时目录,彻底避免把 mock 配置写进生产库(曾导致生产 base_url 被改成 127.0.0.1)。 // Git Bash 风格路径 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原成 C:\Users\...; // 规范化后写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确的 Windows 绝对路径。 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 cfgFile = path.join(DATA, 'config.json'); const cfgBak = cfgFile + '.bak'; const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null; const tokenFile = path.join(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); // SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json, // 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。 const dbFile = path.join(DATA, 'ny.db'); const dbBak = dbFile + '.bak'; const hasDb = fs.existsSync(dbFile); if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} } 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); } // 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的) try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile); } catch(e) { console.error('cleanup db 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) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---'); // 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离) const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', { full_score: 150, pass_rate: 70, exempt: 'S1,S2', }); chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body)); chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective && pc1.body.data.effective.full_score === 150, JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective)); chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective && Number(pc1.body.data.effective.pass_rate) === 70, JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective)); // 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95, // 所以这里不能写死 100,必须与响应里的 global 比对) const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2'); const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null; const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null; chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective && pc2.body.data.effective.full_score === gFs, JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs); chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective && Number(pc2.body.data.effective.pass_rate) === Number(gPr), JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr); // 免考名单往返 const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1'); const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt; chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2', JSON.stringify(ex)); // 清除本卷设置 → 回到全局 const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', { full_score: '', pass_rate: '', exempt: '', }); chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective && pc4.body.data.effective.full_score === gFs, JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs); 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); });