identify() 原来「令牌优先」:令牌对得上就用 X-Editor-* 头的身份,没有头时默认 admin。 国内线路的 nginx 目前不注入令牌,但一旦配置回滚到「注入令牌」的旧版本,编辑的请求 会命中令牌分支 → 静默获得管理员权限(看到全部文章、能改别人的、能全量发布)。 改为「会话(真实的人)优先于共享令牌(服务身份)」,把边界写死在代码里而不是靠配置。 新增断言:编辑会话 + 管理员令牌头 → 仍按编辑身份。role-perm.mjs 47/47
474 lines
21 KiB
JavaScript
474 lines
21 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 跑
|
||
|
||
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();
|
||
|
||
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);
|
||
}
|