Files
ny-exam-platform/tools/test-login-loop.js
T
nxxy335 f661862d46 fix(auth): 修复登录后反复报未配置账号并跳回登录页的死循环
根因:ny_save_config 只写不校验,Db::settingsSet 内部吞异常且返回 void。
data/ 目录不可写时登录页照常显示登录成功,但下一请求读到的账号仍是空的
→ 抛未配置卷王登录账号 → 前端 auth 打标 → 跳登录页 → 无限循环。

修复:
- Db::settingsSet 改返回 bool 并回读校验;新增 settingsDelete
- ny_save_config 双写后均做回读校验,失败写入 ny_config_save_error()
- 新增 ny_config_diag()/ny_session_usable(),暴露来源/可写性/是否已存账号
- ny_load_config 把 null 账号强制成空字符串(脏数据不再伪装成未配置)
- login.php 存凭据后回读确认,存不住/会话不可写直接报错而不是假成功
- login.php 账号为空时页面直接给出 data 目录写权限提示
- config_save 合并逻辑抽成 ny_apply_config_input:账号/密码不许被改空
- 鉴权失败响应带 extra.diag,前端 401 也算 auth
- 前端防死循环:刚登录 120s 内或已跳 2 次则停止自动跳转,弹窗给出真实原因+诊断

测试:新增 tests/test-config-persist.php(29) 与 tools/test-login-loop.js(19);
run-all.sh 纳入两者。全套 148+48=196 项通过。
2026-09-14 08:44:09 +08:00

187 lines
9.5 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: 登录后立刻又报"未配置账号"、反复跳登录页的死循环
//
// 复现前提:本机配置里账号为空(或没存住)。旧代码 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);
})();