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 项通过。
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user