feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)

① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
This commit is contained in:
tamakyi
2026-10-14 12:37:07 +08:00
parent a3a683ff0e
commit 752fad412b
20 changed files with 2631 additions and 339 deletions
+369
View File
@@ -0,0 +1,369 @@
/**
* shiroko-monitor 管理端验证:2FA 绑定、登录、会话、限流与管理接口
*
* 覆盖:
* [A] TOTP / Base32 —— RFC 4226 与 RFC 6238 官方测试向量(算法正确性的地基)
* [B] 2FA 档案 —— 绑定流程、恢复码一次性、session 生命周期、防重放、失败限流
* [C] HTTP 接口 —— 未绑定时一律 403、越权路径、cookie 属性、私有配置写入
* [D] 页面 —— 未绑定只见绑定流程;绑定后需 2FA 才能进管理界面
*
* 用法:node scripts/verify-admin.cjs
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium } = require('playwright');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 8891;
const SETUP_KEY = 'verify-admin-setup-key';
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
const group = (t) => console.log(`\n${t}`);
// 与服务端 lib/totp.js 同一套实现,用它算出「App 会显示的码」
const lib = path.join(ROOT, 'prober', 'node', 'lib', 'totp.js');
async function loadLib() {
return import(`file:///${lib.replace(/\\/g, '/')}`);
}
async function withServer(env, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-admin-'));
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const cfg = path.join(tmp, 'monitors.json');
const base = JSON.parse(fs.readFileSync(cfg, 'utf8'));
base.settings.dataFilter = 'config';
fs.writeFileSync(cfg, `${JSON.stringify(base, null, 2)}\n`);
// 本地探测配置指向本地夹具,避免真实网络请求拖慢测试
const localCfg = path.join(tmp, 'local.json');
fs.writeFileSync(localCfg, JSON.stringify({ settings: {}, monitors: [] }));
const child = spawn(process.execPath, [PROBER, '--once'], {
env: { ...process.env, PORT: String(PORT), SHIROKO_DATA_DIR: tmp, SHIROKO_ADMIN_SETUP_KEY: env.setupKey ?? SETUP_KEY },
stdio: 'ignore',
});
// --once 会退出,因此另起一个常驻进程供 HTTP 测试
const server = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${localCfg}`, `--out=${path.join(tmp, 'status.json')}`], {
env: { ...process.env, PORT: String(PORT), SHIROKO_DATA_DIR: tmp, SHIROKO_ADMIN_SETUP_KEY: env.setupKey ?? SETUP_KEY },
stdio: 'ignore',
});
await new Promise((r) => setTimeout(r, 4000));
try {
await fn({ tmp, cfg, localCfg });
} finally {
server.kill();
child.kill();
fs.rmSync(tmp, { recursive: true, force: true });
}
}
const req = async (path_, { method = 'GET', body, cookie } = {}) => {
const res = await fetch(`http://localhost:${PORT}${path_}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try { data = await res.json(); } catch { /* 无 JSON 体 */ }
return { status: res.status, data, cookie: res.headers.get('set-cookie') };
};
async function main() {
const { totp, base32Decode, hotp, base32Encode, base32Decode: dec, generateSecret, generateRecoveryCodes, hashRecoveryCode, verifyTotp, otpauthUri } = await loadLib();
const { createAdminAuth } = await import(`file:///${path.join(ROOT, 'prober', 'node', 'lib', 'admin.js').replace(/\\/g, '/')}`);
/* ============ [A] TOTP 算法(RFC 官方向量) ============ */
group('[A] TOTP / Base32 算法(RFC 4226 & RFC 6238 官方测试向量)');
const ascii20 = Buffer.from('12345678901234567890', 'ascii');
const hotpExpected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];
check('RFC 4226 HOTP 十个向量全部匹配', hotpExpected.every((v, i) => hotp(ascii20, i) === v), hotpExpected.slice(0, 3).join(',') + '…');
const totpTimes = [59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000];
const totpExpected = ['94287082', '07081804', '14050471', '89005924', '69279037', '65353130'];
check('RFC 6238 TOTP(SHA1/8 位)六个向量全部匹配', totpTimes.every((t, i) => totp(ascii20, { atMs: t * 1000, digits: 8 }) === totpExpected[i]), totpExpected.join(','));
const sec = generateSecret();
check('生成密钥为 20 字节(160 位,RFC 4226 建议强度)', base32Decode(sec).length === 20, `${base32Decode(sec).length} 字节`);
check('Base32 编解码可往返', base32Encode(base32Decode(sec)) === sec, sec.slice(0, 8) + '…');
check('Base32 解码容忍小写/空格/连字符', Buffer.compare(dec('ABCD EFGH-IJKL'), dec('abcdefghijkl')) === 0, `归一化后 ${dec('abcd efgh-ijkl').length} 字节`);
const uri = otpauthUri({ secret: sec, label: 'shiroko', issuer: 'shiroko-monitor' });
check('otpauth 链接含 secret/digits/period/issuer', /secret=/.test(uri) && /digits=6/.test(uri) && /period=30/.test(uri) && /issuer=shiroko-monitor/.test(uri), uri.slice(0, 70) + '…');
const good = totp(base32Decode(sec));
check('正确验证码通过校验', verifyTotp(sec, good) === null, good);
check('错误验证码被拒', verifyTotp(sec, '000000') === 'invalid-code' || verifyTotp(sec, good) === 'invalid-code');
check('格式非法的输入被拒', verifyTotp(sec, 'abc') === 'invalid-format', '');
check('验证码容错 ±1 个时间窗', verifyTotp(sec, totp(base32Decode(sec), { atMs: Date.now() - 30000 })) === null, '30 秒前');
check('超窗(5 分钟前)的码被拒', verifyTotp(sec, totp(base32Decode(sec), { atMs: Date.now() - 5 * 60000 })) === 'invalid-code', '');
const rc = generateRecoveryCodes(10);
check('恢复码 10 枚且格式为 XXXXX-XXXXX', rc.length === 10 && rc.every((c) => /^[A-Z2-9]{5}-[A-Z2-9]{5}$/.test(c)), rc[0]);
check('恢复码不含易混淆字符 0/O/1/I', rc.join('').replace(/-/g, '').split('').every((ch) => !"01IO".includes(ch)), '');
check('恢复码哈希对小写/去连字符/带空格均一致', (() => {
const s = 'salt123';
const h = hashRecoveryCode(rc[0], s);
return h === hashRecoveryCode(rc[0].toLowerCase(), s) && h === hashRecoveryCode(rc[0].replace('-', ''), s) && h === hashRecoveryCode(` ${rc[0]} `, s);
})(), '');
/* ============ [B] 2FA 档案与会话 ============ */
group('[B] 2FA 档案:绑定 / 会话 / 防重放 / 限流');
const btmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-2fa-'));
let clock = Date.now();
const A = createAdminAuth({ storePath: path.join(btmp, 'admin.json'), setupKey: SETUP_KEY, now: () => clock });
check('初始状态为未绑定', A.isBound() === false, JSON.stringify(A.status()));
check('错误绑定口令被拒', A.beginSetup('wrong', '1.1.1.1').error === 'bad-setup-key');
const s1 = A.beginSetup(SETUP_KEY, '10.0.0.1');
check('正确口令换取密钥', s1.ok && s1.secret.length === 32 && s1.uri.startsWith('otpauth://'), `${s1.secret.slice(0, 8)}…`);
check('未完成绑定时不可登录', A.login('123456', '10.0.0.1').error === 'not-bound');
check('错误验证码无法完成绑定', A.confirmSetup(s1.setupToken, '000000').ok === false);
check('错误的 setupToken 被拒', A.confirmSetup('forged', '000000').error === 'no-setup');
clock += 11 * 60 * 1000;
check('绑定流程 10 分钟后失效', A.confirmSetup(s1.setupToken, '000000').error === 'setup-expired');
const s2 = A.beginSetup(SETUP_KEY, '10.0.0.1');
const bound = A.confirmSetup(s2.setupToken, totp(base32Decode(s2.secret), { atMs: clock }));
check('正确验证码完成绑定并生成 10 枚恢复码', bound.ok && bound.recoveryCodes.length === 10, `首枚 ${bound.recoveryCodes[0]}`);
check('绑定后不能再次绑定', A.beginSetup(SETUP_KEY, '10.0.0.1').error === 'already-bound');
check('档案落盘在 data 目录(已 gitignore)', fs.existsSync(path.join(btmp, 'admin.json')), 'admin.json');
const stored = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
check('落盘文件不含恢复码明文', !stored.recovery?.some?.((h) => bound.recoveryCodes.includes(h)) && typeof stored.recovery[0] === 'string' && stored.recovery[0].length === 64, '仅存 sha256');
const l1 = A.login(bound.recoveryCodes[0], '2.2.2.2');
check('恢复码可登录', l1.ok && l1.via === 'recovery', '');
check('会话 id 为高熵随机串', l1.sessionId.length >= 40, `${l1.sessionId.length} 字符`);
check('有效会话可校验通过', A.verifySession(l1.sessionId) !== null);
check('伪造的会话 id 被拒', A.verifySession('forged-session') === null);
check('同一枚恢复码不能复用', A.login(bound.recoveryCodes[0], '2.2.2.2').ok === false);
check('剩余恢复码减 1', A.status().recoveryLeft === 9, `${A.status().recoveryLeft} 枚`);
const store2 = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
const code2 = totp(base32Decode(store2.secret), { atMs: clock });
const l2 = A.login(code2, '3.3.3.3');
check('TOTP 可登录', l2.ok && l2.via === 'totp', code2);
check('同一时间步的码不能重放', A.login(code2, '3.3.3.3').error === 'replayed');
clock += 31000;
const code3 = totp(base32Decode(store2.secret), { atMs: clock });
check('下一个时间窗的码可用', A.login(code3, '3.3.3.3').ok === true, code3);
// 登出与空闲超时
A.destroySession(l1.sessionId);
check('登出后会话立即失效', A.verifySession(l1.sessionId) === null);
clock += 31 * 60 * 1000;
check('超过 30 分钟空闲的会话失效', A.verifySession(l2.sessionId) === null);
check('超过 2 小时的最长会话失效', (() => {
clock -= 31 * 60 * 1000;
const l = A.login(totp(base32Decode(store2.secret), { atMs: clock }), '4.4.4.4');
clock += 2 * 60 * 60 * 1000 + 1000;
return A.verifySession(l.sessionId) === null;
})(), '');
// 限流
const L = createAdminAuth({ storePath: path.join(btmp, 'admin.json'), setupKey: SETUP_KEY, now: () => clock });
let last = null;
for (let i = 0; i < 5; i++) last = L.login('000000', '9.9.9.9');
check('连续 5 次失败后锁定该 IP', last.locked === true, `失败 ${last.fails} 次`);
check('锁定期间即使验证码正确也进不去', (() => {
const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
clock += 31 * 1000;
const r = L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '9.9.9.9');
return r.ok === false && r.error === 'locked';
})(), '');
check('其他 IP 不受该 IP 锁定影响', (() => {
const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
clock += 31 * 1000;
return L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '8.8.8.8').ok === true;
})(), '');
check('登录成功即清零该 IP 的失败计数', (() => {
const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
clock += 31 * 1000;
L.login('000000', '7.7.7.7');
clock += 31 * 1000;
const r = L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '7.7.7.7');
return r.ok === true && L.status() !== null;
})(), '');
check('重置需正确的 setup key', A.reset('wrong').ok === false);
check('重置后回到未绑定并清空会话', (() => { const r = A.reset(SETUP_KEY); return r.ok && A.isBound() === false && A.activeSessions() === 0; })(), '');
/* ============ [C] HTTP 接口 ============ */
group('[C] HTTP 接口:鉴权边界与写入落点');
await withServer({}, async ({ tmp, localCfg }) => {
const st = await req('/api/admin/status');
check('未登录可查 2FA 状态(供页面决定显示绑定还是登录)', st.status === 200 && st.data.bound === false, JSON.stringify(st.data).slice(0, 80));
check('状态接口不回显 setup key 本身', !JSON.stringify(st.data).includes(SETUP_KEY), '');
const locked = await req('/api/admin/overview');
check('未绑定时概览接口 403', locked.status === 403 && locked.data.error === 'not-bound', `HTTP ${locked.status}`);
const locked2 = await req('/api/admin/probe-now', { method: 'POST' });
check('未绑定时立即重探 403', locked2.status === 403, `HTTP ${locked2.status}`);
const locked3 = await req('/api/admin/maintenance', { method: 'POST', body: { from: '2030-01-01T00:00:00Z', to: '2030-01-01T01:00:00Z' } });
check('未绑定时新增维护窗口 403', locked3.status === 403, `HTTP ${locked3.status}`);
check('未绑定时写操作没有产生任何文件改动', !fs.existsSync(localCfg) || JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance === undefined, '');
check('错误绑定口令返回 401', (await req('/api/admin/2fa/setup', { method: 'POST', body: { key: 'wrong' } })).status === 401);
const setupRes = await req('/api/admin/2fa/setup', { method: 'POST', body: { key: SETUP_KEY } });
check('正确口令换取密钥', setupRes.status === 200 && setupRes.data.secret?.length === 32, setupRes.data.uri?.slice(0, 50) + '…');
check('绑定响应含 secret 与 uri 但不含恢复码', !setupRes.data.recoveryCodes, '');
const c0 = totp(base32Decode(setupRes.data.secret));
const badConfirm = await req('/api/admin/2fa/confirm', { method: 'POST', body: { setupToken: setupRes.data.setupToken, code: '000000' } });
check('错误验证码无法完成绑定', badConfirm.status === 400, `HTTP ${badConfirm.status}`);
const confirm = await req('/api/admin/2fa/confirm', { method: 'POST', body: { setupToken: setupRes.data.setupToken, code: c0 } });
check('正确验证码完成绑定并返回恢复码', confirm.status === 200 && confirm.data.recoveryCodes?.length === 10, '');
check('重复绑定返回 409', (await req('/api/admin/2fa/setup', { method: 'POST', body: { key: SETUP_KEY } })).status === 409);
// 会话边界
check('已绑定但无会话时概览 401', (await req('/api/admin/overview')).status === 401);
check('伪造会话 cookie 无效', (await req('/api/admin/overview', { cookie: 'shiroko_admin=forged' })).status === 401);
const badLogin = await req('/api/admin/login', { method: 'POST', body: { code: '000000' } });
check('错误验证码登录 401', badLogin.status === 401 && badLogin.data.error === 'invalid-code', `失败计数 ${badLogin.data.fails}`);
const login = await req('/api/admin/login', { method: 'POST', body: { code: c0 } });
check('正确验证码登录 200', login.status === 200, `方式 ${login.data.via}`);
const setCookie = login.cookie ?? '';
check('会话 cookie 为 HttpOnly', /HttpOnly/i.test(setCookie), setCookie.slice(0, 60) + '…');
check('会话 cookie 为 SameSite=Strict', /SameSite=Strict/i.test(setCookie), '');
check('会话 cookie 的 Path 覆盖接口路径', /Path=\//i.test(setCookie), /Path=\/admin/.test(setCookie) ? '仍是 /admin(接口收不到)' : 'Path=/');
const sid = /shiroko_admin=([^;]+)/.exec(setCookie)?.[1];
check('同码不能重放', (await req('/api/admin/login', { method: 'POST', body: { code: c0 } })).data.error === 'replayed');
const ov = await req('/api/admin/overview', { cookie: `shiroko_admin=${sid}` });
check('带会话可读概览', ov.status === 200 && typeof ov.data.stats?.total === 'number', `目标 ${ov.data.stats?.total} 个`);
check('概览含 2FA 状态但不泄露 secret', Boolean(ov.data.twofa) && !JSON.stringify(ov.data.twofa).includes('secret'), '');
// 管理写操作:只落私有层
const pubBefore = fs.readFileSync(path.join(tmp, 'monitors.json'), 'utf8');
const mw = await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { from: '2030-01-01T00:00:00Z', to: '2030-01-01T01:00:00Z', note: '验证窗口' } });
check('新增维护窗口 200', mw.status === 200 && mw.data.entry.note === '验证窗口', '');
check('维护窗口落在私有配置里', JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance?.length === 1, 'local.json');
check('公开配置未被改动', fs.readFileSync(path.join(tmp, 'monitors.json'), 'utf8') === pubBefore, '');
check('无效时间窗口被拒 400', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { from: '2030-01-02T00:00:00Z', to: '2030-01-01T00:00:00Z' } })).status === 400);
check('非法 probeScope 被拒 400', (await req('/api/admin/probe-scope', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { probeScope: 'evil' } })).status === 400);
const scope = await req('/api/admin/probe-scope', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { probeScope: 'all' } });
check('切换探测范围 200', scope.status === 200, scope.data.probeScope);
check('无会话时切换范围 401', (await req('/api/admin/probe-scope', { method: 'POST', body: { probeScope: 'all' } })).status === 401);
check('未知管理接口 404', (await req('/api/admin/nope', { cookie: `shiroko_admin=${sid}` })).status === 404);
// 删除维护窗口
check('删除维护窗口 200', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'DELETE', body: { index: 0 } })).status === 200);
check('删除后窗口为空', (JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance ?? []).length === 0);
check('删除越界序号被拒 400', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'DELETE', body: { index: 99 } })).status === 400);
// 登出
check('登出 200', (await req('/api/admin/logout', { cookie: `shiroko_admin=${sid}`, method: 'POST' })).status === 200);
check('登出后会话失效', (await req('/api/admin/overview', { cookie: `shiroko_admin=${sid}` })).status === 401);
// 超大请求体
const big = await req('/api/admin/login', { method: 'POST', body: { code: 'x'.repeat(200000) } });
check('超大请求体返回 413(而不是断开连接)', big.status === 413 && big.data?.error === 'body-too-large', `HTTP ${big.status}`);
check('超大请求后服务仍存活', (await req('/api/admin/status')).status === 200, '');
const badJson = await fetch(`http://localhost:${PORT}/api/admin/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{not json' });
check('非法 JSON 返回 400', badJson.status === 400, `HTTP ${badJson.status}`);
});
// 未设置 setup key 时绑定应关闭
group('[C2] 未设置 SHIROKO_ADMIN_SETUP_KEY 时绑定关闭');
await withServer({ setupKey: '' }, async () => {
const st = await req('/api/admin/status');
check('状态接口声明未配置绑定口令', st.data.setupKeyConfigured === false, '');
const r = await req('/api/admin/2fa/setup', { method: 'POST', body: { key: '' } });
check('绑定接口返回 503(管理端未开放)', r.status === 503, `HTTP ${r.status}`);
});
/* ============ [D] 页面 ============ */
group('[D] 管理页面:未绑定只见绑定流程,绑定后必须过 2FA');
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
await withServer({}, async () => {
const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(`http://localhost:${PORT}/admin`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(700);
check('/admin 页面可访问且是绑定流程', await page.isVisible('#bind-panel'), '仅显示绑定面板');
check('未绑定时不出现管理面板', !(await page.isVisible('#console-panel')));
check('页面 noindex(不被搜索引擎收录)', (await page.getAttribute('meta[name="robots"]', 'content'))?.includes('noindex'), '');
await page.fill('#setup-key', SETUP_KEY);
await page.click('#setup-start');
await page.waitForSelector('#secret-text', { state: 'visible', timeout: 10000 });
const shownSecret = (await page.textContent('#secret-text')).replace(/\s/g, '');
check('页面展示 Base32 密钥并分组', shownSecret.length === 32, shownSecret.slice(0, 8) + '…');
const qrInk = await page.evaluate(() => {
const c = document.getElementById('qr');
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 128) dark++;
return dark / (d.length / 4);
});
check('二维码已渲染(非空白)', qrInk > 0.3 && qrInk < 0.6, `${(qrInk * 100).toFixed(1)}% 黑点`);
await page.fill('#confirm-code', totp(base32Decode(shownSecret)));
await page.click('#confirm-btn');
await page.waitForSelector('#recovery-list code', { timeout: 10000 });
const codes = await page.$$eval('#recovery-list code', (els) => els.map((e) => e.textContent));
check('绑定完成展示 10 枚恢复码', codes.length === 10, codes[0]);
check('确认绑定前不展示管理面板', !(await page.isVisible('#console-panel')));
await page.click('#recovery-done');
await page.waitForSelector('#console-panel', { state: 'visible', timeout: 15000 });
check('绑定完成后凭本次 2FA 直接进入管理界面', await page.isVisible('#console-panel'));
const stats = (await page.textContent('#admin-stats')).replace(/\s+/g, ' ');
check('管理界面渲染运行统计', /\d+监控数/.test(stats), stats.slice(0, 60));
check('管理界面列出监控目标', (await page.$$('#monitor-list tr')).length > 0, `${(await page.$$('#monitor-list tr')).length} 个`);
check('管理界面展示 2FA 摘要(恢复码余量)', /剩余恢复码\s*10\s*枚/.test((await page.textContent('#twofa-summary')).replace(/\s+/g, ' ')), '');
// 管理操作
await page.click('#mw-add');
await page.waitForTimeout(800);
check('可新增维护窗口', (await page.$$('#mw-list .mw-item')).length === 1, '');
await page.selectOption('#probe-scope', 'all');
await page.waitForTimeout(800);
check('可切换探测范围', /已改为 all/.test(await page.textContent('#action-msg')), '');
await page.selectOption('#data-filter', 'both');
await page.waitForTimeout(800);
check('可切换展示口径', /已改为 both/.test(await page.textContent('#action-msg')), '');
// 会话持久性 + 登出
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(900);
check('刷新后仍处于登录态(cookie 有效)', await page.isVisible('#console-panel'));
await page.click('#logout-btn');
await page.waitForTimeout(1200);
check('登出后回到 2FA 登录页', await page.isVisible('#login-panel'));
check('登出后看不到管理面板', !(await page.isVisible('#console-panel')));
// 错误验证码
await page.fill('#login-code', '000000');
await page.click('#login-btn');
await page.waitForTimeout(600);
check('错误验证码给出可读提示', /验证码不正确/.test(await page.textContent('#login-msg')), (await page.textContent('#login-msg')).trim());
check('管理页面无 JS 错误', errors.length === 0, errors.join(' | ') || '无');
await page.close();
});
await browser.close();
fs.rmSync(btmp, { recursive: true, force: true });
const failed = results.filter((x) => !x.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('失败项:');
for (const f of failed) console.log(` ✗ ${f.name}`);
process.exit(1);
}
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});