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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user