// e2e: 登录后立刻又报"未配置账号"、反复跳登录页的死循环 // // 复现前提:本机配置里账号为空(或没存住)。旧代码 login.php 不看 ny_save_config 结果, // 登录成功页照常显示"登录成功",但下一个请求 ny_load_config() 读到的账号仍是空的 // → 抛"未配置卷王登录账号" → 前端 auth 打标 → 跳登录页 → 死循环。 // // 本测试锁死三件事: // 1) 登录后凭据必须真的落盘(config_get 能读回账号、has_password=true) // 2) 登录后立刻拉数据必须成功(不再出现"未配置账号") // 3) 真出现鉴权失败时,响应必须带 extra.auth + extra.diag(前端据此停止自动跳转并给出可自查信息) // // 运行:node tools/test-login-loop.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 = 18087; 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 MOCK = { user: 'mock', pass: 'surveyking' }; // NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。 // 注意:绝不能回退到项目下的 data/ —— 本测试会清空账密并重建库,落到真实目录等于破坏本地配置 // (曾因默认回退到 data/ 把 ny.db 删掉)。未显式指定时一律新建临时目录。 const os = require('os'); function resolveDataDir() { let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-login-loop-' + process.pid); 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 tokenFile = path.join(DATA, 'cache', 'token.json'); const dbFile = path.join(DATA, 'ny.db'); const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null; const hasDb = fs.existsSync(dbFile); if (hasDb) { try { fs.unlinkSync(dbFile); } catch (e) {} } try { fs.unlinkSync(tokenFile); } catch (e) {} // 关键:模拟"账号没配/没存住"的现场——连接信息是对的,账号密码是空的 fs.writeFileSync(cfgFile, JSON.stringify({ base_url: 'http://127.0.0.1:19911', api_prefix: '/api', username: '', password: '', verify_ssl: false, timeout: 10, refresh_interval: 15, page_size: 50, dedupe: true, include_temp_save: false, name_keywords: '姓名,名字', full_score: 100, layout: 'default', }, null, 2)); let cleanedUp = false, mock = null, srv = null; 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) {} try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); } catch (e) {} try { if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile); } 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: 'pipe' }); srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'pipe' }); 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 : '')); } } const cookieJar = []; let CSRF = ''; const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : ''); function collect(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 req(method, p, body) { return new Promise((resolve, reject) => { if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF }); const data = body ? JSON.stringify(body) : ''; const h = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }; if (jarHeader()) h['Cookie'] = jarHeader(); const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => { const bufs = []; res.on('data', c => bufs.push(c)); res.on('end', () => { collect(res); const raw = Buffer.concat(bufs).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw, loc: res.headers.location }); }); }); r.on('error', reject); if (data) r.write(data); r.end(); }); } function formPost(p, fields) { return new Promise((resolve, reject) => { const data = new URLSearchParams(fields).toString(); const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) }; if (jarHeader()) h['Cookie'] = jarHeader(); const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => { const bufs = []; res.on('data', c => bufs.push(c)); res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location }); }); }); r.on('error', reject); r.write(data); r.end(); }); } const delay = ms => new Promise(r => setTimeout(r, ms)); (async () => { await delay(2200); console.log('\n--- 1) 登录:凭据必须真的落盘(登录前本机账号为空)---'); const lg = await formPost('/login.php', { username: MOCK.user, password: MOCK.pass }); chk('login.php 返回 200/302', lg.status === 200 || lg.status === 302, 'status=' + lg.status); chk('登录页未出现"没能保存到本机配置"', lg.raw.indexOf('没能保存到本机配置') < 0, (lg.raw.match(/没能保存到本机配置[^<]*/) || [''])[0].slice(0, 160)); chk('登录页未出现"登录失败"', lg.raw.indexOf('登录失败') < 0, (lg.raw.match(/登录失败[^<]*/) || [''])[0].slice(0, 160)); chk('拿到会话 cookie', cookieJar.some(c => c.indexOf('NYSESS=') === 0), JSON.stringify(cookieJar)); chk('成功页写入 ny_login_at(前端防重复跳转依据)', lg.raw.indexOf('ny_login_at') >= 0); const ac = await req('GET', '/api.php?action=auth_check'); chk('auth_check 已登录', ac.body && ac.body.data && ac.body.data.auth === true, ac.raw); if (!(ac.body && ac.body.data && ac.body.data.auth)) { cleanup(); process.exit(1); } CSRF = (ac.body && ac.body.data && ac.body.data.csrf_token) || CSRF; const g1 = await req('GET', '/api.php?action=config_get'); CSRF = (g1.body && g1.body.data && g1.body.data.csrf_token) || CSRF; chk('账号已回读到配置', g1.body && g1.body.data && g1.body.data.username === MOCK.user, JSON.stringify(g1.body && g1.body.data && g1.body.data.username)); chk('has_password=true(密码已存)', g1.body && g1.body.data && g1.body.data.has_password === true); chk('响应不含明文密码', !('password' in ((g1.body && g1.body.data) || {}))); console.log('\n--- 2) 登录后立刻拉数据:不应再出现"未配置账号" ---'); const d1 = await req('GET', '/api.php?action=data&projectId=p1&force=1'); chk('data 成功', d1.body && d1.body.ok === true, (d1.raw || '').slice(0, 200)); chk('data 未报未配置账号', (d1.raw || '').indexOf('未配置卷王登录账号') < 0, (d1.raw || '').slice(0, 160)); chk('data 未被标记为鉴权失败', !(d1.body && d1.body.extra && d1.body.extra.auth), JSON.stringify(d1.body && d1.body.extra)); console.log('\n--- 3) 鉴权真失败时:必须带 auth 标记 + 可自查的诊断 ---'); const sv = await req('POST', '/api.php?action=config_save', { password: 'definitely-wrong' }); chk('改错密码已保存', sv.body && sv.body.ok === true, (sv.raw || '').slice(0, 160)); const d2 = await req('GET', '/api.php?action=data&projectId=p1&force=1'); chk('此时 data 失败', d2.body && d2.body.ok === false, (d2.raw || '').slice(0, 160)); chk('extra.auth=true', !!(d2.body && d2.body.extra && d2.body.extra.auth), JSON.stringify(d2.body && d2.body.extra)); const diag = d2.body && d2.body.extra && d2.body.extra.diag; chk('extra.diag 存在', !!diag, JSON.stringify(diag)); if (diag) { chk('diag.has_username=true(账号其实存住了)', diag.has_username === true); chk('diag 含目录/文件/数据库可写标记', 'dir_writable' in diag && 'cfg_writable' in diag && 'db_writable' in diag); chk('diag 含配置来源', typeof diag.storage === 'string' && diag.storage !== ''); } console.log('\n--- 4) 改回正确密码后应恢复 ---'); const sv2 = await req('POST', '/api.php?action=config_save', { password: MOCK.pass }); chk('恢复密码已保存', sv2.body && sv2.body.ok === true); const d3 = await req('GET', '/api.php?action=data&projectId=p1&force=1'); chk('data 恢复正常', d3.body && d3.body.ok === true, (d3.raw || '').slice(0, 160)); console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败'); cleanup(); process.exit(fail === 0 ? 0 : 1); })();