feat: SSL 证书管家 —— 后台面板 + 专属角色 + 国内机放行
集成在 api.200181.xyz(同一个 Worker),国内机 writeapi.usj.cc 同步可用。 新增第三档后台角色 'ssl': - 只拿证书管家钥匙,看不到评论/文章/用户等模块 - 判定正着枚举放行(canManageSSL = admin|ssl),不用排除法, 免得以后新增角色静默获得私钥权限 - 鉴权只认 Bearer 会话,绝不走 isAdminRequest —— 后者有 Artalk 老客户端的 query 兜底,一旦进来的就是 TLS 私钥 数据(复用 RSS_KV,前缀 certkeeper:): - 凭据一条一键,避开 KV 读-改-写无事务导致的并发丢数据 - 私钥/AK-SK 一律 AES-GCM 密文(TOKEN_SECRET 经 PBKDF2 派生) - 列表接口只回显前 4 后 4 位,明文不进内存 - 配置读失败抛错而非返回空,避免一次保存覆盖线上配置 只读监控: - probeTls 走 cloudflare:sockets 拿证书正文,实现到期分级 与「库里记录 vs 线上实测」对比(match/mismatch/live-only/unreachable) - 到期提醒邮件(HTML 已转义) 国内机 editor-api: - identify 识别 ssl 角色;业务分支前白名单,ssl 只能碰 /health、 /admin/session、/admin/logout 与 /api/v2/ssl* - /api/v2/ssl* 反代放行 admin + ssl(RSS 仍是 admin-only) - posts.mjs 越权兜底方向修正:owns() 从「非 editor 即放行」改为 「只有 admin 不受限」,漏进来的 ssl 被当受限编辑而非管理员全放行 - createPost 显式拒绝非写作角色 验证:typecheck ✓ / selftest:ssl 117 项 ✓ / 浏览器 37 项 ✓
This commit is contained in:
1 parent
ca2fb7163d
commit
a40a92f526
16 files changed
+2791
-59
No files matched your search
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 证书管家后台页面的**真实浏览器验证**。
|
||||
*
|
||||
* 为什么不能只靠 node --check 和 tsc:这两个只保证「语法/类型对」,
|
||||
* 不保证「DOM 真的画出来了」。本项目历史上出现过「数据对了但按钮点了没反应」
|
||||
* 这类只有浏览器才能发现的问题。
|
||||
*
|
||||
* 打不通 wrangler dev 是意料之中 —— 本机沙箱会把 127.0.0.1 的连接掐掉
|
||||
* (TCP 到 TIME_WAIT 但 HTTP 响应拿不到)。所以这里换一条路:
|
||||
* · 用 CDP 开一个 **静态页**,把 /api/v2/* 全部用 fetch mock 拦掉
|
||||
* · 用真实的 admin.js + admin.css + index.html(不复制、不简化),只替换数据源
|
||||
* · 角色切换走**真实 boot 流程**:改 localStorage 的 atk.user → reload 页面,
|
||||
* 由 admin.js 自己的 boot()/showApp()/renderTabs() 决定画什么
|
||||
* 这样验证的是「前端代码画不画得出界面」,而不是「后端通不通」——
|
||||
* 后端那一层已经由 tools/selftest-ssl.mjs 覆盖了 117 项。
|
||||
*
|
||||
* 踩坑记录:
|
||||
* · 剥 <script> 时必须**保留 admin.js** 的标签,否则后续 mock 注入的正则
|
||||
* 匹配不到任何东西 → 页面一个脚本都不加载 → 全白 + `state is not defined`
|
||||
* · admin.js 是 IIFE,state/renderTabs/go 都不在全局上,别想着从控制台直接摸
|
||||
*
|
||||
* 用法:node tools/probe-ssl-ui.mjs
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, '..');
|
||||
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
|
||||
const PORT = 9418;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ---------------------------------------------------------------- mock 数据
|
||||
const DAY = 86400000;
|
||||
const MOCK = {
|
||||
'/api/v2/ssl/overview': {
|
||||
domains: [
|
||||
{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 61 * DAY, expireText: '2026-12-07 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 61, level: 'ok' },
|
||||
{ name: 't-t.live', san: ['t-t.live', '*.t-t.live'], dns: 'tencent-tt', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 82 * DAY, expireText: '2026-12-27 00:00:00', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 82, level: 'ok' },
|
||||
{ name: '200181.xyz', san: ['200181.xyz', '*.200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 00:00:00', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 19, level: 'warn' },
|
||||
{ name: 'new.example.com', san: ['new.example.com'], dns: 'cloudflare', deploy: [], disabled: false, hasCert: false, expireAt: null, expireText: null, issuer: '', updatedAt: null, daysLeft: null, level: 'none' },
|
||||
],
|
||||
orphans: ['old.example.org'],
|
||||
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
|
||||
mailEnabled: true,
|
||||
serverTime: '2026-10-06 12:00:00',
|
||||
},
|
||||
'/api/v2/ssl/access': {
|
||||
items: [
|
||||
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
|
||||
{ name: 'cloudflare', type: 'cloudflare', note: '200181.xyz 的 DNS', fields: { apiToken: 'ALTp********9xQz' } },
|
||||
{ name: 'dogecloud', type: 'dogecloud', note: '', fields: { accessKey: 'dk1a********8f00', secretKey: 'mmN7****11bc' } },
|
||||
{ name: 'old-broken', type: '', note: '', fields: {}, unreadable: true },
|
||||
],
|
||||
types: ['tencentcloud', 'cloudflare', 'dogecloud', '1panel', 'acme-eab'],
|
||||
},
|
||||
'/api/v2/ssl/log': {
|
||||
items: [
|
||||
{ at: Date.now() - 60000, level: 'info', action: 'check', message: '群林: 检查 3 个域名,2 个一致,1 个需关注' },
|
||||
{ at: Date.now() - 3600000, level: 'warn', action: 'check', message: '群林: 200181.xyz 剩余 19 天,低于阈值 30' },
|
||||
{ at: Date.now() - 86400000, level: 'info', action: 'config', message: '群林: 保存配置:3 个域名,提醒邮箱 1 个' },
|
||||
{ at: Date.now() - 172800000, level: 'error', action: 'notify', message: '群林: 邮件未配置(缺少 RESEND_API_KEY)' },
|
||||
],
|
||||
},
|
||||
'/api/v2/ssl/certs': { items: [], warnDays: 30 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 造静态页
|
||||
const work = mkdtempSync(join(tmpdir(), 'ssl-ui-'));
|
||||
const pubDir = join(work, 'public');
|
||||
mkdirSync(join(pubDir, 'admin'), { recursive: true });
|
||||
cpSync(join(root, 'public/admin/admin.css'), join(pubDir, 'admin/admin.css'));
|
||||
cpSync(join(root, 'public/admin/admin.js'), join(pubDir, 'admin/admin.js'));
|
||||
|
||||
// 真实的 admin.html 骨架。剥掉外链脚本时**必须放过 admin.js**(负向先行),
|
||||
// 否则后面往里注 mock 的正则会匹配不到 —— 这是上一版全白的根因。
|
||||
const realHtml = readFileSync(join(root, 'public/admin/index.html'), 'utf8');
|
||||
const html = realHtml.replace(
|
||||
/<script(?![^>]*admin\.js)[^>]*\bsrc=["'][^"']*["'][^>]*><\/script>/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// mock 注入脚本:在 admin.js 之前执行,把 fetch 换成路由表。
|
||||
// /api/v2/user 动态读 localStorage —— 角色切换测试全靠它(改完 reload 即走真实 boot)。
|
||||
const mockJs = `
|
||||
(function () {
|
||||
const MOCK = ${JSON.stringify(MOCK)};
|
||||
const ok = (obj, status) => new Response(JSON.stringify(obj), {
|
||||
status: status || 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
window.fetch = function (input, init) {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
let path = url;
|
||||
try { path = new URL(url, location.href).pathname; } catch (e) {}
|
||||
if (path === '/api/v2/user') {
|
||||
let u = null;
|
||||
try { u = JSON.parse(localStorage.getItem('atk.user') || 'null'); } catch (e) {}
|
||||
return Promise.resolve(ok({ user: u, is_login: !!u }));
|
||||
}
|
||||
// 让 /admin/session 明确返回「不是国内机模式」,走 Cloudflare 分支
|
||||
if (path.endsWith('/admin/session')) {
|
||||
return Promise.resolve(new Response('{"msg":"Not Found"}', { status: 404, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
if (MOCK[path]) return Promise.resolve(ok(MOCK[path]));
|
||||
return Promise.resolve(ok({}));
|
||||
};
|
||||
// 预置一个已登录的管理员会话,跳过登录门。
|
||||
// ★ 只在缺省时写入 —— 角色切换测试靠「改 atk.user → reload」,
|
||||
// 如果这里每次都重置,reload 后角色又被改回管理员了。
|
||||
if (!localStorage.getItem('atk.token')) localStorage.setItem('atk.token', 'fake-admin-token');
|
||||
if (!localStorage.getItem('atk.user')) localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' }));
|
||||
})();
|
||||
`;
|
||||
writeFileSync(join(pubDir, 'admin', 'mock.js'), mockJs);
|
||||
|
||||
// 注入 mock.js(必须排在 admin.js 前面)
|
||||
const finalHtml = html.replace(
|
||||
/(<script[^>]*src=["'][^"']*admin\.js["'][^>]*><\/script>)/,
|
||||
'<script src="./mock.js"></script>\n$1',
|
||||
);
|
||||
if (finalHtml === html) throw new Error('mock 注入失败:没找到 admin.js 的 script 标签');
|
||||
writeFileSync(join(pubDir, 'admin', 'index.html'), finalHtml);
|
||||
|
||||
// ---------------------------------------------------------------- 起服务 + CDP
|
||||
const { createServer } = await import('node:http');
|
||||
const server = createServer((req, res) => {
|
||||
const p = new URL(req.url, 'http://x').pathname;
|
||||
const file = p === '/' || p === '/admin/' || p === '/admin' ? '/admin/index.html' : p;
|
||||
const full = join(pubDir, file);
|
||||
try {
|
||||
const buf = readFileSync(full);
|
||||
const ext = full.slice(full.lastIndexOf('.'));
|
||||
const ct = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': ct });
|
||||
res.end(buf);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('nf');
|
||||
}
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const pagePort = server.address().port;
|
||||
const URL_ = `http://127.0.0.1:${pagePort}/admin/`;
|
||||
|
||||
const userDir = mkdtempSync(join(tmpdir(), 'cdp-ssl-'));
|
||||
const chrome = spawn(CHROME, [
|
||||
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
|
||||
'--no-first-run', '--disable-gpu', '--window-size=1320,1500', 'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
let fail = 0;
|
||||
const results = [];
|
||||
const t = (name, ok, extra) => {
|
||||
results.push((ok ? ' ✓ ' : ' ✗ ') + name + (ok || !extra ? '' : ' → ' + extra));
|
||||
if (!ok) fail++;
|
||||
};
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
|
||||
await sleep(300);
|
||||
}
|
||||
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
|
||||
const ws = new WebSocket(tab.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
|
||||
});
|
||||
await new Promise((res) => ws.addEventListener('open', res));
|
||||
const send = (method, params = {}) => new Promise((res) => {
|
||||
const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params }));
|
||||
});
|
||||
const ev = async (expr) => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
|
||||
if (r.result && r.result.exceptionDetails) return 'EXC: ' + JSON.stringify(r.result.exceptionDetails.exception);
|
||||
return r.result?.result?.value ?? null;
|
||||
};
|
||||
|
||||
// 收集页面错误 —— 这是「画不出来」类 bug 的第一手证据
|
||||
const errs = [];
|
||||
ws.addEventListener('message', (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.method === 'Runtime.exceptionThrown') {
|
||||
errs.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
}
|
||||
});
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
|
||||
await sleep(3500);
|
||||
|
||||
// ---------- 基本渲染(管理员,走真实 boot)
|
||||
t('登录门已隐藏', (await ev(`document.querySelector('#gate').hidden`)) === true);
|
||||
t('主界面已显示', (await ev(`document.querySelector('#app').hidden`)) === false);
|
||||
t('身份栏是「群林」', String(await ev(`document.querySelector('#who')?.textContent`)).includes('群林'));
|
||||
|
||||
// ---------- 导航里能看到「证书管家」(管理员全量菜单)
|
||||
const navText = await ev(`document.querySelector('#tabs').textContent`);
|
||||
t('导航含「证书管家」', String(navText).includes('证书管家'), String(navText));
|
||||
|
||||
// ---------- 点进证书管家
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); return !!b; })()`);
|
||||
await sleep(2200);
|
||||
|
||||
t('顶部标题为「证书管家」', String(await ev(`document.querySelector('.page-head h2')?.textContent`)).includes('证书管家'));
|
||||
const view = String(await ev(`document.querySelector('#view').innerHTML`));
|
||||
|
||||
t('域名表格已渲染', view.includes('usj.cc') && view.includes('t-t.live'));
|
||||
t('SAN 显示出来了', view.includes('*.usj.cc'));
|
||||
t('签发方显示出来了', view.includes('LiteSSL'));
|
||||
t('临期域名有告警横幅', view.includes('200181.xyz') && view.includes('临期'));
|
||||
t('待登记的域名有「无记录」标记', view.includes('new.example.com'));
|
||||
t('孤儿记录提示出现', view.includes('old.example.org'));
|
||||
|
||||
// 剩余天数的数值
|
||||
t('剩余天数渲染为数字', /61/.test(view) && /19/.test(view));
|
||||
t('「实测」按钮存在', view.includes('ssl-probe'));
|
||||
t('「登记」按钮存在', view.includes('ssl-import'));
|
||||
t('「+ 域名」按钮存在', view.includes('ssl-add-domain'));
|
||||
|
||||
// ---------- 凭据区
|
||||
await sleep(600);
|
||||
const acc = String(await ev(`document.querySelector('#ssl-access')?.innerHTML`));
|
||||
t('凭据列表已渲染', acc.includes('tencent-usj') && acc.includes('cloudflare'));
|
||||
t('★ 凭据只显示脱敏值', acc.includes('AKID********3f2a') && !acc.includes('AKIDabcdefg'));
|
||||
t('解不开的凭据被标出来', acc.includes('old-broken') && acc.includes('读不出'));
|
||||
t('凭据有编辑/删除按钮', acc.includes('ssl-access-edit') && acc.includes('ssl-access-del'));
|
||||
|
||||
// ---------- 日志区
|
||||
const logHtml = String(await ev(`document.querySelector('#ssl-log')?.innerHTML`));
|
||||
t('执行日志已渲染', logHtml.includes('检查 3 个域名') && logHtml.includes('低于阈值'));
|
||||
|
||||
// ---------- 通知设置
|
||||
const notify = String(await ev(`document.querySelector('#ssl-notify-cfg')?.innerHTML`));
|
||||
t('通知收件人回显', notify.includes('177018615@qq.com'));
|
||||
t('提醒天数回显', notify.includes('value="30"'));
|
||||
|
||||
// ---------- 打开「+ 域名」弹窗
|
||||
await ev(`document.querySelector('[data-act="ssl-add-domain"]').click()`);
|
||||
await sleep(900);
|
||||
const modalTitle = await ev(`document.querySelector('#modalTitle')?.textContent`);
|
||||
t('「+ 域名」弹窗打开了', String(modalTitle).includes('新增域名'), String(modalTitle));
|
||||
const modalBody = String(await ev(`document.querySelector('#modalBody')?.innerHTML`));
|
||||
t('弹窗里有 DNS 凭据下拉', modalBody.includes('sdDns') && modalBody.includes('tencent-usj'));
|
||||
t('弹窗里有部署目标复选', modalBody.includes('sdDeploy_dogecloud') && modalBody.includes('sdDeploy_1panel'));
|
||||
t('弹窗里有 SAN 输入', modalBody.includes('sdSan'));
|
||||
await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`);
|
||||
await sleep(400);
|
||||
|
||||
// ---------- 打开凭据编辑弹窗(含脱敏回显)
|
||||
await ev(`document.querySelector('[data-act="ssl-access-edit"][data-name="tencent-usj"]').click()`);
|
||||
await sleep(900);
|
||||
const accModal = String(await ev(`document.querySelector('#modalBody')?.innerHTML`));
|
||||
t('凭据弹窗打开了', accModal.includes('saName') && accModal.includes('saType'));
|
||||
t('★ 凭据弹窗里回显的是脱敏值', accModal.includes('AKID********3f2a'));
|
||||
t('凭据弹窗提示「留空则不改」', accModal.includes('留空则不改') || accModal.includes('留空'));
|
||||
await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`);
|
||||
await sleep(400);
|
||||
|
||||
// ---------- 页面无 JS 异常(管理员阶段)
|
||||
const realErrs = errs.filter((e) => !/favicon|ERR_|Failed to load resource/i.test(String(e)));
|
||||
t('★ 页面无未捕获 JS 异常', realErrs.length === 0, realErrs.slice(0, 3).join(' | '));
|
||||
|
||||
// ---------- ★ SSL 专属角色:改 localStorage → 真实 reload → 走真实 boot
|
||||
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 9, name: '阿美', is_admin: false, role: 'ssl' })); 'ok'`);
|
||||
await send('Page.reload').catch(() => {});
|
||||
await sleep(3000);
|
||||
const sslAppShown = (await ev(`document.querySelector('#app').hidden`)) === false;
|
||||
t('★ SSL 角色能进主界面', sslAppShown);
|
||||
const sslTabs = String(await ev(`document.querySelector('#tabs')?.textContent`));
|
||||
t('★ SSL 角色只看到「证书管家」', sslAppShown && sslTabs.includes('证书管家') && !sslTabs.includes('评论管理') && !sslTabs.includes('文章编辑') && !sslTabs.includes('仪表盘'), sslTabs);
|
||||
const sslView = String(await ev(`document.querySelector('#view').innerHTML`));
|
||||
t('★ SSL 角色落地页就是证书管家', sslAppShown && sslView.includes('usj.cc'), sslView.slice(0, 120));
|
||||
const sslWho = String(await ev(`document.querySelector('#who')?.textContent`));
|
||||
t('★ 身份栏标了 SSL', sslWho.includes('阿美') && sslWho.includes('SSL'), sslWho);
|
||||
|
||||
// ---------- ★ 编辑角色:看不到证书管家
|
||||
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 8, name: '小明', is_admin: false, role: 'editor' })); 'ok'`);
|
||||
await send('Page.reload').catch(() => {});
|
||||
await sleep(3000);
|
||||
const edAppShown = (await ev(`document.querySelector('#app').hidden`)) === false;
|
||||
const edTabs = String(await ev(`document.querySelector('#tabs')?.textContent`));
|
||||
t('★ 编辑角色进得去主界面', edAppShown);
|
||||
t('★ 编辑角色看得到「文章编辑」', edAppShown && edTabs.includes('文章编辑'), edTabs);
|
||||
t('★ 编辑角色看不到「证书管家」', !edTabs.includes('证书管家'), edTabs);
|
||||
|
||||
// ---------- 截图(人眼复核)
|
||||
await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' })); 'ok'`);
|
||||
await send('Page.reload').catch(() => {});
|
||||
await sleep(3000);
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`);
|
||||
await sleep(2000);
|
||||
await ev(`document.documentElement.setAttribute('data-theme','light')`);
|
||||
await sleep(500);
|
||||
{
|
||||
const s = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
|
||||
const out = join(root, '..', '.editor-tmp', 'ssl-panel-light.png');
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, Buffer.from(s.result.data, 'base64'));
|
||||
console.log('\n截图:' + out);
|
||||
}
|
||||
|
||||
console.log('\n' + results.join('\n'));
|
||||
console.log('\n' + '='.repeat(56));
|
||||
console.log(fail ? `失败 ${fail} 项` : '全部通过 ✓');
|
||||
ws.close();
|
||||
} finally {
|
||||
chrome.kill();
|
||||
server.close();
|
||||
}
|
||||
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 证书管家本地自测 —— 不启动 wrangler,直接把编译后的 routes/ssl.ts 跑一遍。
|
||||
*
|
||||
* 为什么不用 wrangler dev:本项目的历史教训是「本地 workerd 的行为和线上不一致」
|
||||
* (例如 PBKDF2 迭代数、Workers Cache),所以凡是**纯逻辑**的部分(鉴权矩阵、
|
||||
* 配置校验、加密往返、脱敏)都在这里用假 KV/假 D1 跑,跑得快也跑得准;
|
||||
* 真正需要边界环境的(TLS 探测、Workers Cache)放到线上验证。
|
||||
*
|
||||
* 用法(两步,先编译再跑):
|
||||
* npm run selftest:ssl
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
const outDir = path.join(root, '.selftest-ssl');
|
||||
|
||||
const toFileUrl = (p) => pathToFileURL(p).href;
|
||||
|
||||
// 编译 src → CJS(只编需要的几个文件;types.ts 是纯类型,会被擦掉)
|
||||
// ★ 必须带 --strict:KVNamespaceListResult 是「完成/未完成」的联合类型,
|
||||
// 只有 strict 下的控制流分析才能按 list_complete 把它收窄(`strict:false`
|
||||
// 时 TS 会把两支合并,读 page.cursor 直接报 TS2339)。
|
||||
// 这也说明「编译过了」和「按项目配置编译过了」是两件事。
|
||||
//
|
||||
// ★ 编译交给**外层 shell** 做(package.json 里的 selftest:ssl),不在这里
|
||||
// spawn —— 本机的沙箱会让 spawnSync 直接 EBUSY(status=null、stdout 全是
|
||||
// undefined,报错信息毫无指向性)。这里只做一件事:产物在不在。
|
||||
if (!fs.existsSync(path.join(outDir, 'routes', 'ssl.js'))) {
|
||||
console.error('找不到编译产物 ' + path.join(outDir, 'routes/ssl.js'));
|
||||
console.error('请先跑:npx tsc src/routes/ssl.ts src/lib/{role,certstore,certvault,certprobe}.ts \\');
|
||||
console.error(' --outDir .selftest-ssl --module commonjs --target es2022 \\');
|
||||
console.error(' --moduleResolution node --strict \\');
|
||||
console.error(' --types ./node_modules/@cloudflare/workers-types \\');
|
||||
console.error(' --skipLibCheck --esModuleInterop --resolveJsonModule');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** ★ toFileUrl 定义在文件顶部(pathToFileURL)—— Windows 路径必须走它,
|
||||
* 手写 `'file://' + p` 在盘符前少一个斜杠,import 会报 ERR_UNSUPPORTED_ESM_URL_SCHEME。 */
|
||||
|
||||
// ---------------------------------------------------------------- 测试脚手架
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
|
||||
function t(name, ok, extra) {
|
||||
if (ok) { pass++; console.log(' ✓ ' + name); }
|
||||
else { fail++; failures.push(name); console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); }
|
||||
}
|
||||
|
||||
function eq(name, got, want) {
|
||||
t(name + `(期望 ${JSON.stringify(want)},实得 ${JSON.stringify(got)})`, JSON.stringify(got) === JSON.stringify(want));
|
||||
}
|
||||
|
||||
/** 内存版 KVNamespace,够用(get / put / delete / list) */
|
||||
function memKV() {
|
||||
const m = new Map();
|
||||
return {
|
||||
_m: m,
|
||||
async get(k) { return m.has(k) ? m.get(k) : null; },
|
||||
async put(k, v) { m.set(k, String(v)); },
|
||||
async delete(k) { m.delete(k); },
|
||||
async list({ prefix = '', cursor } = {}) {
|
||||
const keys = [...m.keys()].filter((k) => k.startsWith(prefix)).sort();
|
||||
return { keys: keys.map((name) => ({ name })), list_complete: true, cursor: undefined, cacheStatus: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const KV = memKV();
|
||||
const ENV = {
|
||||
RSS_KV: KV,
|
||||
TOKEN_SECRET: 'test-secret-please-rotate',
|
||||
ALLOWED_ORIGINS: 'https://api.200181.xyz',
|
||||
DB: {
|
||||
// getAdminUsers 会查 D1;这里给一张空表
|
||||
prepare: () => ({ bind: () => ({ all: async () => ({ results: [] }), first: async () => null }) }),
|
||||
},
|
||||
};
|
||||
|
||||
const ssl = await import(toFileUrl(path.join(outDir, 'routes', 'ssl.js')));
|
||||
const store = await import(toFileUrl(path.join(outDir, 'lib', 'certstore.js')));
|
||||
const vault = await import(toFileUrl(path.join(outDir, 'lib', 'certvault.js')));
|
||||
const role = await import(toFileUrl(path.join(outDir, 'lib', 'role.js')));
|
||||
|
||||
/** 造一个 Ctx */
|
||||
function ctx({ method = 'GET', url = 'https://api.200181.xyz/api/v2/ssl/x', user = null, body, origin } = {}) {
|
||||
const headers = new Headers();
|
||||
if (origin) headers.set('Origin', origin);
|
||||
if (body !== undefined) headers.set('Content-Type', 'application/json');
|
||||
return {
|
||||
env: ENV,
|
||||
req: new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||
url: new URL(url),
|
||||
params: {},
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
const ADMIN = { id: 1, name: '群林', email: 'a@b.c', is_admin: 1, role: 'admin' };
|
||||
const SSLU = { id: 2, name: '阿美', email: 's@b.c', is_admin: 0, role: 'ssl' };
|
||||
const EDITOR = { id: 3, name: '小明', email: 'e@b.c', is_admin: 0, role: 'editor' };
|
||||
const PLAIN = { id: 4, name: '路人', email: 'p@b.c', is_admin: 0, role: '' };
|
||||
|
||||
const j = async (res) => ({ status: res.status, body: await res.json() });
|
||||
|
||||
// ================================================================ 1. 角色
|
||||
console.log('\n[1] 角色归一化与权限判定');
|
||||
eq('roleOf(admin)', role.roleOf(ADMIN), 'admin');
|
||||
eq('roleOf(ssl)', role.roleOf(SSLU), 'ssl');
|
||||
eq('roleOf(editor)', role.roleOf(EDITOR), 'editor');
|
||||
eq('roleOf(普通)', role.roleOf(PLAIN), 'user');
|
||||
eq('roleOf(null)', role.roleOf(null), 'user');
|
||||
t('is_admin=1 优先于 role 列', role.roleOf({ is_admin: 1, role: '' }) === 'admin');
|
||||
t('canManageSSL(admin)=true', role.canManageSSL(ADMIN) === true);
|
||||
t('canManageSSL(ssl)=true', role.canManageSSL(SSLU) === true);
|
||||
t('★ canManageSSL(editor)=false', role.canManageSSL(EDITOR) === false);
|
||||
t('★ canManageSSL(普通)=false', role.canManageSSL(PLAIN) === false);
|
||||
t('canWritePosts(ssl)=false', role.canWritePosts(SSLU) === false);
|
||||
eq('normalizeRole("ssl")', role.normalizeRole('ssl', undefined), 'ssl');
|
||||
eq('normalizeRole("SSL")(大小写容错)', role.normalizeRole('SSL', undefined), 'ssl');
|
||||
eq('normalizeRole("胡说")', role.normalizeRole('胡说', undefined), '');
|
||||
eq('normalizeRole(undefined, true)', role.normalizeRole(undefined, true), 'admin');
|
||||
eq('roleLabel("ssl")', role.roleLabel('ssl'), 'SSL 管理员');
|
||||
|
||||
// ================================================================ 2. 保险箱
|
||||
console.log('\n[2] AES-GCM 保险箱');
|
||||
const secretObj = { type: 'tencentcloud', secretId: 'AKIDabcdefghijklmn', secretKey: 'verysecretkey12345' };
|
||||
const sealed = await vault.sealJson(ENV, secretObj);
|
||||
t('密文带 v1. 前缀', sealed.startsWith('v1.'));
|
||||
t('密文里看不到明文', !sealed.includes('AKIDabcdefghijklmn') && !sealed.includes('verysecretkey'));
|
||||
t('isSealed 认得出', vault.isSealed(sealed) === true);
|
||||
eq('解密往返一致', await vault.openJson(ENV, sealed), secretObj);
|
||||
{
|
||||
const sealed2 = await vault.sealJson(ENV, secretObj);
|
||||
t('★ 同一明文两次加密结果不同(IV 随机)', sealed !== sealed2);
|
||||
}
|
||||
t('isSealed(乱码)=false', vault.isSealed('hello') === false);
|
||||
{
|
||||
const wrongKeyEnv = { ...ENV, TOKEN_SECRET: 'another-secret' };
|
||||
let threw = false;
|
||||
try { await vault.openJson(wrongKeyEnv, sealed); } catch { threw = true; }
|
||||
t('★ 换密钥后解密抛错(不返回半成品)', threw);
|
||||
eq('peekJson 解不开返回 null', await vault.peekJson(wrongKeyEnv, sealed), null);
|
||||
}
|
||||
eq('maskSecret 长串', vault.maskSecret('AKIDabcdefghijklmn'), 'AKID********klmn');
|
||||
eq('maskSecret 短串', vault.maskSecret('abc'), '***');
|
||||
eq('maskSecret 空', vault.maskSecret(''), '');
|
||||
|
||||
// ================================================================ 3. 存储层
|
||||
console.log('\n[3] 存储层(配置 / 凭据 / 证书 / 日志)');
|
||||
{
|
||||
const c0 = await store.loadConfig(ENV);
|
||||
eq('空 KV → 默认配置 version', c0.version, 1);
|
||||
eq('空 KV → 空域名列表', c0.domains, []);
|
||||
eq('空 KV → 默认提醒 30 天', c0.notify.daysBefore, 30);
|
||||
|
||||
await store.saveConfig(ENV, {
|
||||
version: 1,
|
||||
notify: { emails: ['a@b.c'], daysBefore: 21 },
|
||||
domains: [{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tc', deploy: ['1panel'] }],
|
||||
});
|
||||
const c1 = await store.loadConfig(ENV);
|
||||
eq('保存后读回域名数', c1.domains.length, 1);
|
||||
eq('保存后读回提醒天数', c1.notify.daysBefore, 21);
|
||||
|
||||
// 老配置缺字段的迁移
|
||||
await KV.put('certkeeper:config', JSON.stringify({ domains: [{ name: 'x.cc' }] }));
|
||||
const c2 = await store.loadConfig(ENV);
|
||||
eq('缺 notify 时补默认', c2.notify.daysBefore, 30);
|
||||
eq('域名缺 san 时补空数组', c2.domains[0].san, []);
|
||||
eq('域名缺 deploy 时补空数组', c2.domains[0].deploy, []);
|
||||
|
||||
await KV.put('certkeeper:config', '{坏 JSON');
|
||||
let threw = false;
|
||||
try { await store.loadConfig(ENV); } catch { threw = true; }
|
||||
t('★ 配置坏 JSON 时抛错(不返回空配置)', threw);
|
||||
|
||||
// 复位:上面故意写坏的配置要清掉,否则后面每个接口都会 500
|
||||
await KV.delete('certkeeper:config');
|
||||
eq('复位后能正常读配置', (await store.loadConfig(ENV)).version, 1);
|
||||
}
|
||||
|
||||
{
|
||||
await store.putAccess(ENV, 'tc-main', { type: 'tencentcloud', note: '主号', secretId: 'AKIDxyz123456789', secretKey: 'kkkkkkkkkkkk' });
|
||||
const raw = await KV.get('certkeeper:access:tc-main');
|
||||
t('★ KV 里落的是密文(不含明文密钥)', !raw.includes('AKIDxyz123456789') && vault.isSealed(raw));
|
||||
|
||||
const got = await store.getAccess(ENV, 'tc-main');
|
||||
eq('getAccess 解出明文 type', got.type, 'tencentcloud');
|
||||
eq('getAccess 解出明文 secretId', got.secretId, 'AKIDxyz123456789');
|
||||
|
||||
const list = await store.listAccess(ENV);
|
||||
eq('列表有一条', list.length, 1);
|
||||
eq('★ 列表回显 type', list[0].type, 'tencentcloud');
|
||||
eq('★ 列表回显脱敏 secretId', list[0].fields.secretId, 'AKID********6789');
|
||||
t('★ 列表里没有明文密钥', !JSON.stringify(list).includes('AKIDxyz123456789'));
|
||||
t('★ 列表里没有明文 secretKey', !JSON.stringify(list).includes('kkkkkkkkkkkk'));
|
||||
|
||||
// 解不开的凭据也要列出来(密钥轮换后的场景)
|
||||
await KV.put('certkeeper:access:broken', 'v1.AAAA.BBBB');
|
||||
const list2 = await store.listAccess(ENV);
|
||||
const broken = list2.find((x) => x.name === 'broken');
|
||||
t('★ 解不开的凭据仍出现在列表里', !!broken);
|
||||
t('★ 解不开的凭据被标 unreadable', broken && broken.unreadable === true);
|
||||
await KV.delete('certkeeper:access:broken');
|
||||
}
|
||||
|
||||
{
|
||||
const rec = { cert: 'PEM', key: 'KEY', expireAt: Date.now() + 86400000, updatedAt: Date.now(), issuer: 'LiteSSL' };
|
||||
await store.putCert(ENV, 'USJ.CC', rec); // 大小写混写
|
||||
eq('★ 证书域名自动小写化后读得到', (await store.getCert(ENV, 'usj.cc')).issuer, 'LiteSSL');
|
||||
eq('大写也读得到同一张', (await store.getCert(ENV, 'USJ.CC')).issuer, 'LiteSSL');
|
||||
const raw = await KV.get('certkeeper:cert:usj.cc');
|
||||
t('★ 证书(含私钥)落 KV 是密文', vault.isSealed(raw) && !raw.includes('KEY'));
|
||||
eq('listCertNames', await store.listCertNames(ENV), ['usj.cc']);
|
||||
|
||||
await KV.put('certkeeper:cert:bad.cc', 'v1.XXXX.YYYY');
|
||||
eq('★ 证书解不开时返回 null 而不是抛', await store.getCert(ENV, 'bad.cc'), null);
|
||||
await KV.delete('certkeeper:cert:bad.cc');
|
||||
}
|
||||
|
||||
{
|
||||
for (let i = 0; i < 205; i++) {
|
||||
await store.appendLog(ENV, { at: i, level: 'info', action: 'check', message: 'm' + i });
|
||||
}
|
||||
const logs = await store.loadLog(ENV);
|
||||
eq('★ 日志环形缓冲上限 200', logs.length, 200);
|
||||
eq('日志最新在最前', logs[0].message, 'm204');
|
||||
await store.clearLog(ENV);
|
||||
eq('清空后为空', (await store.loadLog(ENV)).length, 0);
|
||||
}
|
||||
|
||||
// ================================================================ 4. 鉴权矩阵
|
||||
console.log('\n[4] HTTP 鉴权矩阵(这是最关键的一组)');
|
||||
{
|
||||
const cases = [
|
||||
['匿名 GET /overview', null, 403],
|
||||
['普通用户 GET /overview', PLAIN, 403],
|
||||
['★ 编辑 GET /overview(不该有证书权限)', EDITOR, 403],
|
||||
['SSL 管理员 GET /overview', SSLU, 200],
|
||||
['管理员 GET /overview', ADMIN, 200],
|
||||
];
|
||||
for (const [name, user, want] of cases) {
|
||||
const r = await ssl.overview(ctx({ user }));
|
||||
eq(name, r.status, want);
|
||||
}
|
||||
|
||||
// 写接口
|
||||
const wcases = [
|
||||
['匿名 POST /config', null, 403],
|
||||
['★ 编辑 POST /config', EDITOR, 403],
|
||||
['SSL 管理员 POST /config(合法体)', SSLU, 200],
|
||||
];
|
||||
for (const [name, user, want] of wcases) {
|
||||
const r = await ssl.configSave(ctx({
|
||||
method: 'POST', user,
|
||||
body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] },
|
||||
}));
|
||||
eq(name, r.status, want);
|
||||
}
|
||||
|
||||
// CSRF
|
||||
{
|
||||
const r = await ssl.configSave(ctx({
|
||||
method: 'POST', user: ADMIN, origin: 'https://evil.example.com',
|
||||
body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] },
|
||||
}));
|
||||
eq('★ 跨站 Origin 的写请求被拒', r.status, 403);
|
||||
}
|
||||
{
|
||||
const r = await ssl.configSave(ctx({
|
||||
method: 'POST', user: ADMIN, origin: 'https://api.200181.xyz',
|
||||
body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] },
|
||||
}));
|
||||
eq('同源写请求放行', r.status, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================ 5. 配置校验
|
||||
console.log('\n[5] 配置校验(错了要在保存时就报,别等 90 天后)');
|
||||
{
|
||||
await store.putAccess(ENV, 'tc1', { type: 'tencentcloud', secretId: 'a', secretKey: 'b' });
|
||||
|
||||
const bad = [
|
||||
['缺 name', { domains: [{ san: ['a.cc'], dns: 'tc1' }] }],
|
||||
['不像域名', { domains: [{ name: 'not a domain!', san: ['a.cc'], dns: 'tc1' }] }],
|
||||
['缺 SAN', { domains: [{ name: 'a.cc', san: [], dns: 'tc1' }] }],
|
||||
['缺 DNS 凭据名', { domains: [{ name: 'a.cc', san: ['a.cc'] }] }],
|
||||
['★ 引用了不存在的凭据', { domains: [{ name: 'a.cc', san: ['a.cc'], dns: 'nope' }] }],
|
||||
['不认识的部署目标', { domains: [{ name: 'a.cc', san: ['a.cc'], dns: 'tc1', deploy: ['k8s'] }] }],
|
||||
['重复域名', { domains: [
|
||||
{ name: 'a.cc', san: ['a.cc'], dns: 'tc1' },
|
||||
{ name: 'a.cc', san: ['a.cc'], dns: 'tc1' },
|
||||
] }],
|
||||
];
|
||||
for (const [name, body] of bad) {
|
||||
const r = await ssl.configSave(ctx({
|
||||
method: 'POST', user: ADMIN,
|
||||
body: { version: 1, notify: { emails: [], daysBefore: 30 }, ...body },
|
||||
}));
|
||||
t('拒绝:' + name, r.status === 400, 'got ' + r.status);
|
||||
}
|
||||
|
||||
const okBody = {
|
||||
version: 1,
|
||||
notify: { emails: ['me@example.com'], daysBefore: 45 },
|
||||
domains: [{ name: 'USJ.CC', san: ['USJ.CC', '*.USJ.CC'], dns: 'tc1', deploy: ['1panel', 'dogecloud'] }],
|
||||
};
|
||||
const r = await ssl.configSave(ctx({ method: 'POST', user: ADMIN, body: okBody }));
|
||||
eq('合法配置保存成功', r.status, 200);
|
||||
const saved = await store.loadConfig(ENV);
|
||||
eq('★ 域名自动小写化', saved.domains[0].name, 'usj.cc');
|
||||
eq('★ SAN 自动小写化', saved.domains[0].san, ['usj.cc', '*.usj.cc']);
|
||||
eq('提醒阈值保存正确', saved.notify.daysBefore, 45);
|
||||
|
||||
// 提醒天数越界
|
||||
for (const days of [0, -5, 400]) {
|
||||
const rr = await ssl.configSave(ctx({
|
||||
method: 'POST', user: ADMIN,
|
||||
body: { version: 1, notify: { emails: [], daysBefore: days }, domains: [] },
|
||||
}));
|
||||
t('拒绝越界提醒天数 ' + days, rr.status === 400, 'got ' + rr.status);
|
||||
}
|
||||
// 邮箱格式
|
||||
{
|
||||
const rr = await ssl.configSave(ctx({
|
||||
method: 'POST', user: ADMIN,
|
||||
body: { version: 1, notify: { emails: ['not-an-email'], daysBefore: 30 }, domains: [] },
|
||||
}));
|
||||
eq('拒绝非法邮箱', rr.status, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================ 6. 凭据接口
|
||||
console.log('\n[6] 凭据接口');
|
||||
{
|
||||
const r = await ssl.accessSave(ctx({
|
||||
method: 'POST', user: ADMIN,
|
||||
body: { name: 'new-acc', type: 'cloudflare', note: 'CF', fields: { apiToken: 'cf-token-abcdefgh' } },
|
||||
}));
|
||||
eq('新建凭据', r.status, 200);
|
||||
eq('凭据类型落库', (await store.getAccess(ENV, 'new-acc')).type, 'cloudflare');
|
||||
|
||||
// 只改备注:不该把脱敏值(含 ****)写进去
|
||||
const r2 = await ssl.accessSave(ctx({
|
||||
method: 'POST', user: ADMIN,
|
||||
body: { name: 'new-acc', type: 'cloudflare', note: '改过备注', fields: { apiToken: 'cf-t********gh' } },
|
||||
}));
|
||||
eq('只改备注返回 200', r2.status, 200);
|
||||
const after = await store.getAccess(ENV, 'new-acc');
|
||||
eq('★ 脱敏值没被当成新密钥写进去', after.apiToken, 'cf-token-abcdefgh');
|
||||
eq('备注已更新', after.note, '改过备注');
|
||||
|
||||
// 非法类型 / 名字
|
||||
const r3 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: 'x', type: '随便', fields: { a: 'b' } } }));
|
||||
eq('拒绝非法类型', r3.status, 400);
|
||||
const r4 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: '有空格 和/斜杠', type: 'cloudflare', fields: { a: 'b' } } }));
|
||||
eq('拒绝非法凭据名', r4.status, 400);
|
||||
const r5 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: 'never-existed', type: 'cloudflare', fields: {} } }));
|
||||
eq('拒绝「新建但没填密钥」', r5.status, 400);
|
||||
|
||||
// 被引用的凭据不能删
|
||||
const r6 = await ssl.accessDelete(ctx({ method: 'POST', user: ADMIN, body: { name: 'tc1' } }));
|
||||
eq('★ 被域名引用的凭据删不掉', r6.status, 409);
|
||||
const r7 = await ssl.accessDelete(ctx({ method: 'POST', user: ADMIN, body: { name: 'new-acc' } }));
|
||||
eq('未被引用的凭据可删', r7.status, 200);
|
||||
eq('删除后读不到', await store.getAccess(ENV, 'new-acc'), null);
|
||||
}
|
||||
|
||||
// ================================================================ 7. 证书登记
|
||||
console.log('\n[7] 证书登记 / 删除');
|
||||
{
|
||||
const pem = '-----BEGIN CERTIFICATE-----\nTUlJQmRENDAdGVzdA==\n-----END CERTIFICATE-----';
|
||||
const r = await ssl.certImport(ctx({
|
||||
method: 'POST', user: ADMIN,
|
||||
body: { domain: 'usj.cc', cert: pem, key: '', expireAt: Date.UTC(2027, 0, 5), issuer: 'LiteSSL' },
|
||||
}));
|
||||
eq('登记证书', r.status, 200);
|
||||
const rec = await store.getCert(ENV, 'usj.cc');
|
||||
eq('到期时间落库', rec.expireAt, Date.UTC(2027, 0, 5));
|
||||
eq('签发方落库', rec.issuer, 'LiteSSL');
|
||||
|
||||
const r2 = await ssl.certImport(ctx({
|
||||
method: 'POST', user: ADMIN, body: { domain: 'x.cc', cert: '不是 PEM', expireAt: 1 },
|
||||
}));
|
||||
eq('拒绝非 PEM 内容', r2.status, 400);
|
||||
const r3 = await ssl.certImport(ctx({
|
||||
method: 'POST', user: ADMIN, body: { domain: 'y.cc', cert: pem, expireAt: 0 },
|
||||
}));
|
||||
eq('★ 读不出到期时间且没填 → 拒绝', r3.status, 400);
|
||||
|
||||
// certList
|
||||
const rl = await j(await ssl.certList(ctx({ user: ADMIN })));
|
||||
eq('certList 状态码', rl.status, 200);
|
||||
const item = rl.body.items.find((x) => x.domain === 'usj.cc');
|
||||
t('certList 带上 configured 标记', item && item.configured === true);
|
||||
t('certList 算出剩余天数', item && typeof item.daysLeft === 'number' && item.daysLeft > 0);
|
||||
t('certList 按剩余天数升序', rl.body.items.every((x, i, a) => i === 0 || (a[i - 1].daysLeft ?? 9999) <= (x.daysLeft ?? 9999)));
|
||||
|
||||
const rd = await ssl.certDelete(ctx({ method: 'POST', user: ADMIN, body: { domain: 'usj.cc' } }));
|
||||
eq('删除证书', rd.status, 200);
|
||||
eq('删除后读不到', await store.getCert(ENV, 'usj.cc'), null);
|
||||
}
|
||||
|
||||
// ================================================================ 8. 日志接口
|
||||
console.log('\n[8] 日志 / whoami');
|
||||
{
|
||||
await store.appendLog(ENV, { at: Date.now(), level: 'warn', action: 'check', message: '测试日志' });
|
||||
const r = await j(await ssl.logList(ctx({ user: SSLU })));
|
||||
eq('SSL 管理员能读日志', r.status, 200);
|
||||
t('日志有内容', r.body.items.length >= 1);
|
||||
|
||||
const rw = await j(await ssl.whoami(ctx({ user: SSLU })));
|
||||
eq('whoami 对 SSL 管理员返回 ok', rw.status, 200);
|
||||
eq('whoami 带出角色', rw.body.user.role, 'ssl');
|
||||
|
||||
const rw2 = await j(await ssl.whoami(ctx({ user: EDITOR })));
|
||||
eq('★ whoami 对编辑返回 401', rw2.status, 401);
|
||||
eq('whoami 明确 canManage=false', rw2.body.canManage, false);
|
||||
}
|
||||
|
||||
// ================================================================ 9. 概览分级
|
||||
console.log('\n[9] 概览的到期分级');
|
||||
{
|
||||
await store.saveConfig(ENV, {
|
||||
version: 1,
|
||||
notify: { emails: [], daysBefore: 30 },
|
||||
domains: [
|
||||
{ name: 'ok.cc', san: ['ok.cc'], dns: 'tc1', deploy: [] },
|
||||
{ name: 'soon.cc', san: ['soon.cc'], dns: 'tc1', deploy: [] },
|
||||
{ name: 'dead.cc', san: ['dead.cc'], dns: 'tc1', deploy: [] },
|
||||
{ name: 'off.cc', san: ['off.cc'], dns: 'tc1', deploy: [], disabled: true },
|
||||
{ name: 'none.cc', san: ['none.cc'], dns: 'tc1', deploy: [] },
|
||||
],
|
||||
});
|
||||
const day = 86400000;
|
||||
await store.putCert(ENV, 'ok.cc', { cert: '', key: '', expireAt: Date.now() + 80 * day, updatedAt: Date.now() });
|
||||
await store.putCert(ENV, 'soon.cc', { cert: '', key: '', expireAt: Date.now() + 10 * day, updatedAt: Date.now() });
|
||||
await store.putCert(ENV, 'dead.cc', { cert: '', key: '', expireAt: Date.now() - 3 * day, updatedAt: Date.now() });
|
||||
await store.putCert(ENV, 'off.cc', { cert: '', key: '', expireAt: Date.now() + 5 * day, updatedAt: Date.now() });
|
||||
|
||||
const r = await j(await ssl.overview(ctx({ user: ADMIN })));
|
||||
const by = Object.fromEntries(r.body.domains.map((d) => [d.name, d]));
|
||||
eq('80 天 → ok', by['ok.cc'].level, 'ok');
|
||||
eq('10 天 → warn', by['soon.cc'].level, 'warn');
|
||||
eq('已过期 → danger', by['dead.cc'].level, 'danger');
|
||||
eq('★ 已停用的域名不报临期', by['off.cc'].level, 'none');
|
||||
eq('★ 已过期剩余天数为负', by['dead.cc'].daysLeft, -3);
|
||||
eq('没证的 → none', by['none.cc'].level, 'none');
|
||||
eq('没证的 hasCert=false', by['none.cc'].hasCert, false);
|
||||
}
|
||||
|
||||
// ================================================================ 10. 邮件 HTML
|
||||
console.log('\n[10] 提醒邮件 HTML 转义');
|
||||
{
|
||||
const html = ssl.certMailHtml([{ domain: '<script>alert(1)</script>.cc', days: -2 }], 30);
|
||||
t('★ 邮件里域名被 HTML 转义', !html.includes('<script>') && html.includes('<script>'));
|
||||
t('邮件含「已过期」文案', html.includes('已过期'));
|
||||
}
|
||||
|
||||
// ================================================================ 汇总
|
||||
console.log('\n' + '='.repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
if (fail) {
|
||||
console.log('\n失败清单:');
|
||||
for (const f of failures) console.log(' · ' + f);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('全部通过 ✓');
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
Reference in new issue
Block a user