Files
ny-exam-platform/tools/test-login-loop.js
T
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +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/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);
})();