Files
blog/editor-api/test/role-perm.mjs
T
zqlit 432cf5e398 feat: 证书探测改走国内机真 Node —— Workers 拿不到证书正文的兜底方案
根因:Workers 上 cloudflare:sockets 没有 getPeerCertificate,
node:tls 的同名方法是桩函数(调用即抛 not implemented)。

- editor-api 新增 /ssl-probe 本地端点(真 Node,读对端证书正文):
  不外发、不落盘;ssl 白名单放行,admin/ssl 可用,editor/匿名拒绝
- certprobe 改两级:首选国内机(带 X-Editor-Token),失败自动降级本地握手
- certProbe/certCheck 接线 EDITOR_API_BASE + EDITOR_TOKEN,撤掉 ?debug 诊断
- wrangler.toml 显式开 nodejs_compat(compat date 早于默认启用阈值)
- 手写 node:tls 最小类型声明(保持零依赖)
- seed-ssl-config.mjs 净化:真实凭据移到 secrets-backup/certkeeper-seeds.json,
  TOKEN_SECRET 改从 .dev.vars 读;脚本本体不含任何凭据
- role-perm 新增第 9 节 12 项(59/59),UI 探测 37 项全过
2026-10-06 15:55:22 +08:00

587 lines
26 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.
/**
* 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);
}