导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
187 lines
9.5 KiB
JavaScript
187 lines
9.5 KiB
JavaScript
// 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/8.2.34/php.exe';
|
||
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||
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);
|
||
})();
|