/** * editor-api「管理员 / 编辑」角色权限矩阵 —— 端到端测试。 * * node test/role-perm.mjs * * 自己搭一个一次性 git 仓库(含 3 篇不同归属的文章)跑真实 HTTP 请求, * 测完连临时目录一起删掉 —— 不碰真仓库、不碰线上。 * * 覆盖的是这次改造的核心承诺: * · 编辑只能看/改/删/传图到自己的文章(含老文章按昵称认领的兜底) * · 编辑建文章时归属与署名由服务端钉死,前端伪造 author_id 无效 * · 编辑发布只提交自己的文章目录,不会把别人未提交的改动一起带走 * · 「国内线路」一键跳转不能把编辑变成管理员(签名覆盖身份) * · 管理员能力与改造前一致 */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; const PORT = 8099; const BASE = `http://127.0.0.1:${PORT}`; const TOKEN = 'test-token-' + crypto.randomBytes(4).toString('hex'); const ADMIN_PASS = 'local-admin-pass'; const ADMIN = { uid: '1', name: '群林', role: 'admin' }; const EDITOR = { uid: '7', name: '小美', role: 'editor' }; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'editor-role-')); const BLOG = path.join(TMP, 'blog'); const TRASH = path.join(TMP, 'trash'); const ORIGIN = path.join(TMP, 'origin.git'); let pass = 0; const fails = []; function check(name, cond, extra = '') { if (cond) { pass++; console.log(' ✅ ' + name); } else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ❌ ' + name + (extra ? ' → ' + extra : '')); } } function section(t) { console.log('\n' + t); } // ------------------------------------------------------------------ 搭仓库 // 用异步 execFile,不用 execFileSync —— // 本机(Windows + 沙箱)同步版 spawn 一律 EBUSY,异步版正常。 // editor-api 自己的 git.mjs 用的也是异步,所以线上没这个问题。 function sh(cwd, args) { return new Promise((resolve, reject) => { execFile('git', args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => { if (err) reject(Object.assign(err, { stdout, stderr })); else resolve(stdout); }); }); } function writePost(dirName, fmLines, body) { const dir = path.join(BLOG, 'content', 'posts', '2026', dirName); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.md'), ['---', ...fmLines, '---', '', body, ''].join('\n'), 'utf8'); return dir; } async function setupRepo() { fs.mkdirSync(BLOG, { recursive: true }); await sh(TMP, ['init', '--bare', '-b', 'main', ORIGIN]); await sh(BLOG, ['init', '-b', 'main']); await sh(BLOG, ['config', 'user.name', 'tester']); await sh(BLOG, ['config', 'user.email', 'tester@local']); // ① 小美(id=7)的文章 writePost('2026-10-01-妈妈的菜-20261001093000', [ 'title: 妈妈的菜', 'date: 2026-10-01', 'slug: 20261001093000', 'author: 小美', 'author_id: 7', 'layout: post', 'tags: []', 'categories: []', 'draft: false', ], '今天做了红烧肉。'); // ② 群林(id=1)的文章 writePost('2026-10-02-我的随笔-20261002120000', [ 'title: 我的随笔', 'date: 2026-10-02', 'slug: 20261002120000', 'author: 群林', 'author_id: 1', 'layout: post', 'tags: []', 'categories: []', 'draft: false', ], '随便写点。'); // ③ 老文章:没有 author_id,只能按昵称认领(改造前 write-server 写的那些) writePost('2026-10-03-老文章-20261003150000', [ 'title: 老文章', 'date: 2026-10-03', 'slug: 20261003150000', 'author: 小美', 'layout: post', 'tags: []', 'categories: []', 'draft: false', ], '很久以前写的。'); await sh(BLOG, ['add', '-A']); await sh(BLOG, ['commit', '-m', 'init']); await sh(BLOG, ['remote', 'add', 'origin', ORIGIN]); await sh(BLOG, ['push', '-u', 'origin', 'main']); } const DIR_MINE = '2026-10-01-妈妈的菜-20261001093000'; const DIR_ADMIN = '2026-10-02-我的随笔-20261002120000'; const DIR_LEGACY = '2026-10-03-老文章-20261003150000'; // ------------------------------------------------------------------ 起服务 const child = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], { env: { ...process.env, PORT: String(PORT), BIND_HOST: '127.0.0.1', BLOG_ROOT: BLOG, TRASH_DIR: TRASH, SESSION_FILE: path.join(TMP, 'sessions.json'), EDITOR_TOKEN: TOKEN, ADMIN_USER: 'admin', ADMIN_PASS, GIT_BRANCH: 'main', PUSH_REMOTES: 'origin', GIT_PATHS: 'content,static', // 故意指向一个连不上的地址:验证「CF 不可达时回退本机管理员」这条应急通道 RSS_API_BASE: 'http://127.0.0.1:9', }, stdio: ['ignore', 'pipe', 'pipe'], }); let serverLog = ''; child.stdout.on('data', (d) => (serverLog += d)); child.stderr.on('data', (d) => (serverLog += d)); async function waitUp() { for (let i = 0; i < 50; i++) { try { const r = await fetch(BASE + '/health'); if (r.ok) return true; } catch {} await new Promise((s) => setTimeout(s, 120)); } return false; } async function call(method, p, { body, ident, headers = {}, cookie, raw } = {}) { const h = { ...headers }; if (ident) { h['X-Editor-Token'] = TOKEN; h['X-Editor-Uid'] = ident.uid; h['X-Editor-User'] = encodeURIComponent(ident.name); h['X-Editor-Role'] = ident.role; } if (cookie) h.Cookie = cookie; if (body !== undefined && !raw) h['Content-Type'] = 'application/json'; const res = await fetch(BASE + p, { method, headers: h, body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined, redirect: 'manual', }); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } return { status: res.status, data, cookie: res.headers.get('set-cookie') }; } const P = (id) => '/posts/' + encodeURIComponent(id); const readFile = (dirName) => fs.readFileSync(path.join(BLOG, 'content', 'posts', '2026', dirName, 'index.md'), 'utf8'); /** * ⑧ 直连登录的**主路径**:国内机把账号密码转发给 Cloudflare 的用户表校验。 * 用一个假的 CF(只实现 /api/v2/user/access_token)来测 —— * 不依赖真线上账号,也能覆盖「编辑能不能从国内线路登录」这条。 */ async function sectionCfLogin() { section('⑧ 直连登录走 Cloudflare 用户表(主路径)'); const CF_PORT = 8098; const PORT2 = 8097; const http = await import('node:http'); const stub = http.createServer((req, res) => { let buf = ''; req.on('data', (c) => (buf += c)); req.on('end', () => { if (!req.url.startsWith('/api/v2/user/access_token')) { res.writeHead(404).end('{}'); return; } const body = JSON.parse(buf || '{}'); const okPair = (body.email === 'xiaomei' && body.password === 'good-pass') || (body.email === 'someone' && body.password === 'plain-pass') || (body.email === 'admin' && body.password === 'admin-pass'); if (!okPair) { res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"msg":"Unauthorized"}'); return; } const users = { xiaomei: { id: 7, name: '小美', role: 'editor', is_admin: false }, admin: { id: 1, name: '群林', role: 'admin', is_admin: true }, // 普通评论用户:有账号,但没写作后台权限 someone: { id: 42, name: '路人', role: 'user', is_admin: false }, }; res.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ token: 'tok', user: users[body.email] }), ); }); }); await new Promise((s) => stub.listen(CF_PORT, '127.0.0.1', s)); const srv2 = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], { env: { ...process.env, PORT: String(PORT2), BIND_HOST: '127.0.0.1', BLOG_ROOT: BLOG, TRASH_DIR: TRASH, SESSION_FILE: path.join(TMP, 'sessions2.json'), EDITOR_TOKEN: TOKEN, ADMIN_USER: 'admin', ADMIN_PASS, GIT_BRANCH: 'main', PUSH_REMOTES: 'origin', RSS_API_BASE: `http://127.0.0.1:${CF_PORT}`, }, stdio: ['ignore', 'pipe', 'pipe'], }); let log2 = ''; srv2.stdout.on('data', (d) => (log2 += d)); srv2.stderr.on('data', (d) => (log2 += d)); const B2 = `http://127.0.0.1:${PORT2}`; for (let i = 0; i < 50; i++) { try { if ((await fetch(B2 + '/health')).ok) break; } catch {} await new Promise((s) => setTimeout(s, 120)); } const call2 = async (p, body) => { const res = await fetch(B2 + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), redirect: 'manual', }); const text = await res.text(); let data; try { data = JSON.parse(text); } catch { data = { raw: text }; } return { status: res.status, data, cookie: res.headers.get('set-cookie') }; }; const post = async (p, body, cookie) => { const res = await fetch(B2 + p, { method: 'GET', headers: cookie ? { Cookie: cookie } : {}, }); return { status: res.status, data: await res.json().catch(() => null) }; }; let r = await call2('/admin/login', { user: 'xiaomei', password: 'good-pass' }); check('编辑账号从国内线路登录 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140)); check('会话角色是 editor(不是 admin)', r.data.user && r.data.user.role === 'editor' && r.data.user.name === '小美', JSON.stringify(r.data.user)); const ck = String(r.cookie).split(';')[0]; r = await post('/posts', null, ck); check('编辑登录后只看到自己的文章', r.data && r.data.total === 3, 'total=' + (r.data && r.data.total)); r = await call2('/admin/login', { user: 'someone', password: 'plain-pass' }); check('普通评论用户 → 401(没有写作后台权限)', r.status === 401, 'got ' + r.status); r = await call2('/admin/login', { user: 'xiaomei', password: 'bad-pass' }); check('密码错(CF 可达)→ 401', r.status === 401, 'got ' + r.status); r = await call2('/admin/login', { user: 'admin', password: 'admin-pass' }); check('管理员账号 → 200 且角色 admin', r.status === 200 && r.data.user.role === 'admin', JSON.stringify(r.data.user)); srv2.kill(); stub.close(); } // ------------------------------------------------------------------ 跑 /** * ⑨ SSL 管理员与证书探针。 * * 这一节单独起一个 server(RSS_API_BASE 指到死地址),因为要验证的是 * **本机白名单**:/ssl-probe 是本地实现、不反代出去,所以它必须能在 * 「连不上 Cloudflare」的情况下照常工作(证书管家就是为这种时候准备的)。 */ async function sectionSslRole() { section('⑨ SSL 管理员与证书探针'); const PORT3 = 8096; const srv = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], { env: { ...process.env, PORT: String(PORT3), BIND_HOST: '127.0.0.1', BLOG_ROOT: BLOG, TRASH_DIR: TRASH, SESSION_FILE: path.join(TMP, 'sessions3.json'), EDITOR_TOKEN: TOKEN, ADMIN_USER: 'admin', ADMIN_PASS, GIT_BRANCH: 'main', PUSH_REMOTES: 'origin', // 故意指向连不上的地址:证明 /ssl-probe 不依赖 Cloudflare RSS_API_BASE: 'http://127.0.0.1:1', }, stdio: ['ignore', 'pipe', 'pipe'], }); let log3 = ''; srv.stdout.on('data', (d) => (log3 += d)); srv.stderr.on('data', (d) => (log3 += d)); const B3 = `http://127.0.0.1:${PORT3}`; for (let i = 0; i < 50; i++) { try { if ((await fetch(B3 + '/health')).ok) break; } catch {} await new Promise((s) => setTimeout(s, 120)); } const call3 = async (method, p, { ident, headers = {} } = {}) => { const h = { ...headers }; if (ident) { h['X-Editor-Token'] = TOKEN; h['X-Editor-Uid'] = ident.uid; h['X-Editor-User'] = encodeURIComponent(ident.name); h['X-Editor-Role'] = ident.role; } const res = await fetch(B3 + p, { method, headers: h, redirect: 'manual' }); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } return { status: res.status, data }; }; const SSLMGR = { uid: '9', name: '证书管理员', role: 'ssl' }; // ---- SSL 管理员:证书管家相关放行 ---- // /ssl-probe 是本地实现,缺 host 应回 400(说明走到了实现处), // 而不是 403(被白名单拦下)。 let r = await call3('GET', '/ssl-probe', { ident: SSLMGR }); check('SSL 管理员能进 /ssl-probe(缺 host → 400,不是 403)', r.status === 400, 'got ' + r.status + ' ' + JSON.stringify(r.data)); // 非法域名属于「探针跑完但结果是否定的」,用 200 + ok:false 表达业务结果, // 不用 4xx —— 前端要能把它当普通探测结果渲染,而不是当接口报错。 r = await call3('GET', '/ssl-probe?host=' + encodeURIComponent('不合法域名'), { ident: SSLMGR }); check('非法域名 → 200 且 ok=false(业务结果,不是接口错误)', r.status === 200 && r.data.ok === false, 'got ' + r.status + ' ' + JSON.stringify(r.data)); // ---- SSL 管理员:写作/评论相关一律拒 ---- r = await call3('GET', '/posts', { ident: SSLMGR }); check('SSL 管理员列文章 → 403', r.status === 403, 'got ' + r.status); r = await call3('POST', '/posts', { ident: SSLMGR }); check('SSL 管理员新建文章 → 403', r.status === 403, 'got ' + r.status); r = await call3('GET', '/git/status', { ident: SSLMGR }); check('SSL 管理员看 git 状态 → 403', r.status === 403, 'got ' + r.status); r = await call3('GET', '/health', { ident: SSLMGR }); check('SSL 管理员能看 /health(白名单)', r.status === 200, 'got ' + r.status); // ---- 编辑 / 管理员:探针也要按角色放行 ---- r = await call3('GET', '/ssl-probe', { ident: ADMIN }); check('管理员能进 /ssl-probe', r.status === 400, 'got ' + r.status); r = await call3('GET', '/ssl-probe?host=example.com', { ident: EDITOR }); check('编辑进 /ssl-probe → 403(不是给编辑用的)', r.status === 403, 'got ' + r.status); // ---- 匿名:不能进(这条路径不是公开探测服务)---- r = await call3('GET', '/ssl-probe?host=example.com'); check('匿名进 /ssl-probe → 401', r.status === 401, 'got ' + r.status); // ---- 真网络探测:拿不到不算失败(沙箱/无外网),拿到就校验字段形状 ---- r = await call3('GET', '/ssl-probe?host=usj.cc', { ident: ADMIN }); if (r.status === 200 && r.data.ok && r.data.notAfter) { check('真探测 usj.cc 拿到 notAfter(毫秒时间戳)', typeof r.data.notAfter === 'number' && r.data.notAfter > Date.now(), String(r.data.notAfter)); check('真探测带上签发方', !!r.data.issuer, JSON.stringify({ issuer: r.data.issuer })); check('真探测带上 SAN', Array.isArray(r.data.altNames) && r.data.altNames.length > 0, JSON.stringify(r.data.altNames)); } else { console.log(' ⚠️ 跳过真网络探测(本次连不上 usj.cc:' + (r.data && r.data.error) + ')'); } srv.kill(); } // ------------------------------------------------------------------ 跑 async function main() { await setupRepo(); if (!(await waitUp())) { console.error('服务没起来:\n' + serverLog); process.exit(1); } section('① 身份门禁'); let r = await call('GET', '/posts'); check('不带凭据 → 401', r.status === 401, 'got ' + r.status); r = await call('GET', '/posts', { headers: { 'X-Editor-Token': TOKEN } }); check('只带令牌、无身份头 → 视为管理员(兼容 curl / 旧 Worker)', r.status === 200 && r.data.total === 3, JSON.stringify(r.data).slice(0, 120)); r = await call('GET', '/posts', { headers: { 'X-Editor-Token': 'wrong', 'X-Editor-Uid': '7', 'X-Editor-User': '%E5%B0%8F%E7%BE%8E', 'X-Editor-Role': 'editor' } }); check('令牌不对 → 401(伪造身份头没用)', r.status === 401, 'got ' + r.status); r = await call('GET', '/posts', { ident: EDITOR }); check('编辑看到 2 篇(自己 + 老的昵称认领),看不到群林那篇', r.status === 200 && r.data.total === 2 && !r.data.posts.some((p) => p.id === DIR_ADMIN), JSON.stringify(r.data.posts?.map((p) => p.id))); r = await call('GET', '/posts', { ident: ADMIN }); check('管理员看到全部 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total); section('② 编辑不能碰别人的文章'); r = await call('GET', P(DIR_ADMIN), { ident: EDITOR }); check('打开别人的文章 → 403', r.status === 403, 'got ' + r.status); r = await call('PUT', P(DIR_ADMIN), { ident: EDITOR, body: { content: '我改' } }); check('改别人的文章 → 403', r.status === 403, 'got ' + r.status); check('别人的文章内容没被动', readFile(DIR_ADMIN).includes('随便写点。')); r = await call('DELETE', P(DIR_ADMIN), { ident: EDITOR }); check('删别人的文章 → 403', r.status === 403, 'got ' + r.status); check('别人的文章还在原处', fs.existsSync(path.join(BLOG, 'content/posts/2026', DIR_ADMIN, 'index.md'))); r = await call('POST', '/upload?key=' + encodeURIComponent(DIR_ADMIN), { ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'), headers: { 'Content-Type': 'image/png' }, }); check('往别人文章目录传图 → 403', r.status === 403, 'got ' + r.status); r = await call('POST', '/upload', { ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'), headers: { 'Content-Type': 'image/png' }, }); check('不带 key 传图(公共图库)→ 403', r.status === 403, 'got ' + r.status); section('③ 编辑自己的文章:正常能力 + 老文章认领'); r = await call('PUT', P(DIR_LEGACY), { ident: EDITOR, body: { content: '很久以前写的。改一个字。' } }); check('改自己(老文章按昵称认领)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140)); const legacyText = readFile(DIR_LEGACY); check('老文章保存后补上了 author_id: 7(正式划到自己名下)', /author_id:\s*7\b/.test(legacyText), legacyText.slice(0, 200)); check('老文章正文改到了', legacyText.includes('改一个字')); r = await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '今天做了红烧肉,很好吃。' } }); check('改自己(有 author_id)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140)); r = await call('POST', '/upload?name=a.png&key=' + encodeURIComponent(DIR_MINE), { ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'), headers: { 'Content-Type': 'image/png' }, }); check('往自己文章目录传图 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140)); check('图片确实落在自己文章目录里', r.data.dir === 'content/posts/2026/' + DIR_MINE, String(r.data.dir)); section('④ 新建文章的归属由服务端钉死'); r = await call('POST', '/posts', { ident: EDITOR, body: { frontMatter: { title: '伪造归属测试', slug: 'fakeowner' + Date.now(), author: '群林', author_id: 1 }, content: '正文', }, }); check('新建 → 200', r.status === 200 && r.data.id, JSON.stringify(r.data).slice(0, 140)); const created = readFile(r.data.id); check('author_id 被强制成 7(前端传的 1 无效)', /author_id:\s*7\b/.test(created), created.slice(0, 240)); check('author 被强制成「小美」(不能替别人署名)', /author:\s*小美/.test(created), created.slice(0, 240)); section('⑤ 发布范围:编辑只提交自己的目录'); // 两边各改一处:编辑改自己的,管理员改自己的 —— 编辑发布时不能顺走管理员那笔 await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '准备发布' } }); await call('PUT', P(DIR_ADMIN), { ident: ADMIN, body: { content: '管理员自己的未提交改动' } }); r = await call('GET', '/git/status', { ident: EDITOR }); check('编辑看到的状态是「限定范围」的', r.status === 200 && r.data.scoped === true, JSON.stringify(r.data).slice(0, 160)); check('编辑只看到自己目录的改动', (r.data.files || []).every((f) => f.includes(DIR_MINE) || f.includes(DIR_LEGACY) || f.includes('fakeowner')), JSON.stringify(r.data.files)); r = await call('GET', '/git/status', { ident: ADMIN }); check('管理员看到全量改动(含编辑的和自己的)', r.data.scoped === false && (r.data.files || []).some((f) => f.includes(DIR_MINE)), JSON.stringify(r.data.files)); r = await call('POST', '/git/publish', { ident: EDITOR, body: { message: '编辑:发布我的' } }); check('编辑发布 → 成功', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 200)); check('返回里标明是限定范围发布', r.data.scoped === true, String(r.data.scoped)); // core.quotepath=false:中文目录名默认会被 git 转成 \346\210\221 八进制转义, // 拿它跟原始目录名比字符串必然对不上(编辑器里已经踩过一次同样的坑) const committed = await sh(BLOG, ['-c', 'core.quotepath=false', 'show', '--stat', '--oneline', 'HEAD']); check('提交里没有管理员的文章', !committed.includes(DIR_ADMIN), committed.slice(0, 300)); check('提交里有编辑自己的文章', committed.includes(DIR_MINE), committed.slice(0, 300)); const stillDirty = await sh(BLOG, ['-c', 'core.quotepath=false', 'status', '--porcelain']); check('管理员那篇仍是未提交状态(没被顺走)', stillDirty.includes(DIR_ADMIN), stillDirty); section('⑥ 国内线路一键跳转不能越权'); const ts = String(Date.now()); const sigFor = (uid, name, role) => crypto.createHmac('sha256', TOKEN).update(`${ts}\n${uid}\n${name}\n${role}`).digest('hex'); r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=deadbeef`); check('签名错 → 403', r.status === 403, 'got ' + r.status); r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=${sigFor('7', '小美', 'editor')}`); check('编辑的正确签名 → 302 + 发 Cookie', r.status === 302 && !!r.cookie, 'got ' + r.status); // 拿这枚 Cookie 冒充管理员操作 const edCookie = String(r.cookie).split(';')[0]; r = await call('GET', P(DIR_ADMIN), { cookie: edCookie }); check('用这枚 Cookie 打开管理员那篇 → 仍 403(跳转没有把编辑变成管理员)', r.status === 403, 'got ' + r.status); r = await call('GET', '/posts', { cookie: edCookie }); // 到这一步编辑名下有 3 篇了:自己的、按昵称认领的老文章、以及第 ④ 节新建的那篇 check('用这枚 Cookie 列文章 → 只看到自己的 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total); // ★ 会话优先于共享令牌。国内线路的 nginx 目前不注入令牌,但万一配置漂移回 // 「注入令牌」的旧版本,请求会同时带着管理员身份的令牌头 + 编辑的浏览器会话。 // 此时必须以「人」为准 —— 否则编辑会静默拿到管理员权限。 r = await call('GET', '/posts', { cookie: edCookie, ident: ADMIN }); check('编辑会话 + 管理员令牌头 → 仍按编辑身份(只看自己 3 篇)', r.status === 200 && r.data.total === 3, 'total=' + r.data.total); // 把签名载荷里的 r 改成 admin(签名仍是按 editor 算的)→ 必须拒 r = await call('GET', `/admin/handoff?ts=${String(Date.now())}&u=7&n=%E5%B0%8F%E7%BE%8E&r=admin&t=${sigFor('7', '小美', 'editor')}`); check('把角色改成 admin 但签名不匹配 → 403', r.status === 403, 'got ' + r.status); section('⑦ 直连登录(CF 不可达时的应急通道)'); // 本测试故意把 RSS_API_BASE 指到连不上的地址,模拟「国内机连不上 Cloudflare」。 // 此时账号密码**无法校验**,如实回 503 比谎报 401「密码错」有用得多 // (线上 CF 正常时,密码错会由 CF 回 401 → 这里回 401,见下一条)。 r = await call('POST', '/admin/login', { body: { user: 'admin', password: 'wrong' } }); check('CF 不可达 + 不是本机管理员 → 503(如实说「校验不了」)', r.status === 503, 'got ' + r.status); r = await call('POST', '/admin/login', { body: { user: 'admin', password: ADMIN_PASS } }); check('本机管理员账号 → 200(CF 连不上也能进)', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140)); const admCookie = String(r.cookie).split(';')[0]; check('会话带上角色 admin', r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data.user)); r = await call('GET', '/posts', { cookie: admCookie }); check('这枚 Cookie 能看到全部 3+1 篇', r.status === 200 && r.data.total >= 4, 'total=' + r.data.total); r = await call('GET', '/admin/session', { cookie: admCookie }); check('会话探测返回身份(含 role)', r.status === 200 && r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data)); r = await call('GET', '/admin/session'); check('没会话时 → 401 且 mode=direct', r.status === 401 && r.data.mode === 'direct', JSON.stringify(r.data)); await sectionCfLogin(); await sectionSslRole(); console.log('\n' + '─'.repeat(60)); console.log(`通过 ${pass} / ${pass + fails.length}`); if (fails.length) { console.log('\n失败项:'); for (const f of fails) console.log(' · ' + f); } return fails.length ? 1 : 0; } let code = 1; try { code = await main(); } catch (e) { console.error('测试异常崩溃:', e); } finally { child.kill(); await new Promise((s) => setTimeout(s, 250)); try { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 3 }); } catch (e) { console.error('(临时目录没删干净,可手动删:' + TMP + ')'); } process.exit(code); }