Files
blog/editor-api/test/role-perm.mjs
T

586 lines
26 KiB
JavaScript
Raw Normal View History

/**
* 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);
}