Files
blog/blog-admin/tools/selftest-ssl.mjs
T
zqlit a40a92f526 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 项 ✓
2026-10-06 14:19:01 +08:00

476 lines
22 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.
/**
* 证书管家本地自测 —— 不启动 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('&lt;script&gt;'));
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 });