Files
tamakyi 752fad412b 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 项全部通过
2026-10-14 12:37:07 +08:00

369 lines
23 KiB
JavaScript
Raw Permalink 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.
/**
* 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);
});