Files
inkwatch/tests/admin-api-test.js
jkooe 7c9c2f0605 refactor(rename): pmon → inkwatch 数据层迁移(阶段 B:库名/角色/卷名/连接串)
## 做了什么(顺序有意义)

1. **备份**:`pg_dump -Fc` → 152MB dump,落在仓库外
   `.backup-rename-20261108/`,并留了改名前后的表行数文件。
2. **停应用**(nginx/business/gateway),保留 PG/Redis。
3. `ALTER DATABASE pmon RENAME TO inkwatch` + `ALTER ROLE pmon RENAME TO inkwatch`
   + `ALTER ROLE inkwatch WITH PASSWORD 'inkwatch_dev_only'`。
4. **复制卷**(不是改名):`pmon_pg_data → inkwatch_pg_data`(3.807GB)、
   Redis、nginx 日志三个卷。**旧卷全部保留**,作回滚点。
5. 改 compose 的卷名与环境变量默认值、`deploy/` 下全部脚本/配置、测试默认连接串、
   `.env.example`、`docs/`。
6. 用新 compose 起栈,删旧容器(`pmon-*` 共 6 个,含 9 天前的 migrate 容器)。

## 数据校验:这是本次唯一不能靠"应该没事"的部分

第一次我拿 `pg_stat_user_tables.n_live_tup` 当基线,**那是错的** —— 它是估算值,
实测 `devices` 估 58、实际 14559,`device_metrics` 估 0、实际 257 万。用它做校验
等于没有校验。

改成的办法:把 dump **恢复到一个临时库**,然后两张库**逐表精确 `count(*)` 对比**
(排除 `_hyper_*` chunk,父表计数已覆盖)。结果:

```
唯一差异:device_metrics  当前 2572652 / 备份 2572456  → 当前多 196 行
```

多出来而不是少了 —— 因为 dump 是 20:04:57 取的,而停服在 20:07:53,期间设备仍在
正常上报。最新数据时间戳 `12:07:53 UTC` 佐证了这一点。**196 行是正常写入,不是差异。**
逐表其它 40+ 张表行数完全一致。

校验库随后删除,容器内临时 dump 也删了。

## 两个踩到的坑(都已记录在流程里)

**① `docker exec` 不加 `-i` 时 stdin 不转发。** 我第一次用 heredoc 把四条 SQL 喂给
`psql`,结果**一条都没执行、也不报错** —— 看起来像"执行了但没生效"。改成 `-c` 逐条。

**② `ALTER ROLE` 不能重命名当前会话用户**(`session user cannot be renamed`)。
解法是建一个临时超级用户来执行,用完立刻删(已验证 `pg_roles` 里查不到它)。
注意 `DROP ROLE` 同样不能删当前用户,必须换回原身份再删。

## 有意没做的事

- **旧卷没删**:`pmon_pg_data`(3.807GB) / `pmon_redis_data` / `pmon_nginx_logs` 都留着。
  这是唯一的整体回滚点。确认新栈稳定运行一段时间后,可自行
  `docker volume rm pmon_pg_data pmon_redis_data pmon_nginx_logs`。
- **`pmon_dbg1` / `pmon_claim_verify` 两个遗留库没动**:它们是历史测试/调试残留,
  不是产品库。要清可以直接 `DROP DATABASE`,但删数据这种事我不代做决定。

## 验证

新栈 5 个容器全 `healthy`,`/health` business 与 gateway 均 **200**;
库内 `users=128 devices=14559`,与迁移前一致。

服务端套件(全部自起源码实例或纯离线):
认领 **57/0**、埋点 **29/0**、配对码来源 **16/0**、权限 **60/0**、schema **116/0**
—— 与改名前的数字**完全一致**。

客户端上一提交已验证 **595 通过 / 2 失败**(两条为 `libinkwatch_core.dylib` 未编译)。
2026-11-08 20:12:25 +08:00

464 lines
23 KiB
JavaScript
Raw Permalink 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.
'use strict';
/**
* 云端管理端 —— 鉴权、数据口径、写动作、CSV、失败关闭。
*
* ── 为什么要单独一套 ────────────────────────────────────────────────
* 管理端是**全站作用域**的接口:它能列出所有账号、导出所有邮箱、
* 改任何人的档位、封任何人的号。用户视角的 38 个端点每一个都带
* `WHERE owner_id = 我`,而这里一个都没有 —— 唯一的边界就是那道口令。
* 所以这套测试的重点不是"功能能用",而是**四种"没配好"的形态都不放行**:
*
* ① 没设 ADMIN_TOKEN → 404(不是 401:不对外宣布它存在)
* ② 设了但太短(< 32 字符) → 同样 404(半个管理端比没有更危险)
* ③ 口令错 → 401,且不回显任何片段
* ④ 口令走 query 而非 header → 401(?token= 会进 nginx 日志与浏览器历史)
*
* 另一半是"不许漏出来的东西":任何响应体里都不得出现
* `secret_hash` / 完整 `token_hash`;CSV 里的用户可控字段必须挡公式注入。
*
* ── 前置 ───────────────────────────────────────────────────────────
* PG + Redis。业务服务**由本套件自己拉起**(随机空闲端口 + 独立 outbox),
* 缺依赖时打印 [skip] 并退出 0 —— 不冒充通过,也不记失败。
*
* 运行:node admin-api-test.js
*/
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const DB_URL = process.env.DATABASE_URL
|| 'postgres://inkwatch:inkwatch_dev_only@127.0.0.1:5432/inkwatch';
// 口令必须 >= 32 字符(admin.js 的 MIN_TOKEN_LEN)。这里刚好给足,
// 并故意用可读串:一旦它出现在某个响应体里,下面的断言能一眼认出来。
const TOKEN = 'admin-test-token-0123456789abcdefghij';
let pass = 0;
let fail = 0;
const failures = [];
function ok(name, cond, detail) {
if (cond) {
pass++;
console.log(` ✓ ${name}`);
} else {
fail++;
failures.push(name + (detail ? ` <- ${detail}` : ''));
console.log(` ✗ ${name}${detail ? ' <- ' + detail : ''}`);
}
}
const children = [];
function stopAll() {
for (const c of children) {
try { c.kill('SIGTERM'); } catch { /* 已退出 */ }
}
children.length = 0;
}
function freePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const p = srv.address().port;
srv.close(() => resolve(p));
});
});
}
async function waitHealth(base, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(base + '/health');
if (r.ok || r.status === 503) return true;
} catch { /* 还没起来 */ }
await new Promise((r) => setTimeout(r, 200));
}
return false;
}
/**
* 起一个隔离实例。
*
* 每个实例有自己的端口与 outbox:本套件要同时验证"没配口令"与"配了口令"
* 两种形态,共用一个进程是做不到的(口令是启动期读的环境变量)。
*/
async function startBiz(extraEnv) {
const port = await freePort();
const outbox = path.join(os.tmpdir(), `inkwatch-admin-${port}.jsonl`);
try { fs.unlinkSync(outbox); } catch { /* 首次没有 */ }
const p = spawn(process.execPath, ['server/business/index.js'], {
cwd: ROOT,
env: {
...process.env,
BUSINESS_PORT: String(port),
MAIL_OUTBOX_PATH: outbox,
MAIL_MODE: 'outbox', // 库默认是 console(安全侧),联调必须显式声明
ALLOW_OPEN_REGISTER: '1',
REGISTER_IP_LIMIT: '500',
LOGIN_IP_LIMIT: '500',
// 配额开着:第 7 段要确认"封禁会吊销会话"而不是"看起来封了"
ENFORCE_DEVICE_QUOTA: '1',
DATABASE_URL: DB_URL,
REDIS_URL: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
...extraEnv,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
p.stdout.on('data', (d) => { log += d; });
p.stderr.on('data', (d) => { log += d; });
children.push(p);
const base = `http://127.0.0.1:${port}`;
if (!(await waitHealth(base))) {
return { base, ok: false, log };
}
return { base, ok: true, log };
}
/** 发请求;token 传 null 表示不带 Authorization */
async function req(base, method, p, { token, body, raw, bytes } = {}) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const r = await fetch(base + p, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (raw) {
return { status: r.status, text: await r.text(), headers: r.headers };
}
if (bytes) {
// ⚠️ 必须用 arrayBuffer 而不是 text():WHATWG 的 `Response.text()` 会
// **按规范把 BOM 剥掉**(UTF-8 decode 默认 ignoreBOM=false),于是
// "文件到底有没有写 BOM"这件事用它根本测不出来 —— 首跑就因此假红一次。
// Buffer.toString('utf8') 不做这个处理,字节原样保留。
return { status: r.status, buf: Buffer.from(await r.arrayBuffer()), headers: r.headers };
}
let json = null;
try { json = await r.json(); } catch { /* 非 JSON */ }
return { status: r.status, body: json || {}, headers: r.headers };
}
async function main() {
// ── 先确认依赖在不在,别把"没起 PG"报成"功能坏了" ──
{
const probe = await startBiz({ ADMIN_TOKEN: TOKEN });
if (!probe.ok) {
console.log('\n[skip] 临时业务服务起不来(多半是 PG / Redis 没起):');
console.log(probe.log.trim().split('\n').slice(-6).map((l) => ' ' + l).join('\n'));
stopAll();
process.exit(0);
}
}
// ═══════════════ ① 没设口令:整体不存在 ═══════════════
console.log('\n【1】未配置 ADMIN_TOKEN → 一切 404');
{
const { base } = await startBiz({ ADMIN_TOKEN: '' });
const a = await req(base, 'GET', '/api/admin/ping');
ok('接口 404(不是 401:不对外宣布"这里有管理台")', a.status === 404, `status=${a.status}`);
const b = await req(base, 'GET', '/admin/', { raw: true });
ok('页面同样 404', b.status === 404, `status=${b.status}`);
const c = await req(base, 'GET', '/api/admin/overview', { token: TOKEN });
ok('拿一个格式正确的口令也没用(没配置就是没配置)', c.status === 404, `status=${c.status}`);
}
// ═══════════════ ② 口令太短:等于没设 ═══════════════
console.log('\n【2】ADMIN_TOKEN 短于 32 字符 → 视为未设置');
{
const { base } = await startBiz({ ADMIN_TOKEN: 'short-token-12345' });
const a = await req(base, 'GET', '/api/admin/ping', { token: 'short-token-12345' });
ok('短口令实例一律 404("看起来设过了"比没设更危险)', a.status === 404, `status=${a.status}`);
}
// ═══════════════ ③ 正常实例:鉴权全枚举 ═══════════════
console.log('\n【3】口令校验');
const { base: B } = await startBiz({ ADMIN_TOKEN: TOKEN, ADMIN_NAME: '测试运营' });
{
const a = await req(B, 'GET', '/api/admin/ping');
ok('不带口令 → 401', a.status === 401, `status=${a.status}`);
ok('错误码为 E_ADMIN_TOKEN_REQUIRED', a.body.error === 'E_ADMIN_TOKEN_REQUIRED', a.body.error);
const b = await req(B, 'GET', '/api/admin/ping', { token: 'wrong-' + 'x'.repeat(30) });
ok('错口令 → 401', b.status === 401, `status=${b.status}`);
ok('响应里不含任何口令片段', !JSON.stringify(b.body).includes(TOKEN.slice(0, 8)));
// 口令走 query:常见错法,必须同样拒绝。
// ?token= 会进 nginx access log、浏览器历史、Referer —— 等于把凭据撒出去。
const c = await req(B, 'GET', `/api/admin/ping?token=${encodeURIComponent(TOKEN)}`);
ok('口令放 query → 401(只认 Authorization 头)', c.status === 401, `status=${c.status}`);
const d = await req(B, 'GET', '/api/admin/ping', { token: TOKEN });
ok('正确口令 → 200', d.status === 200, `status=${d.status}`);
ok('回显操作者名字(审计里要知道是谁)', d.body.operator === '测试运营', d.body.operator);
// 原 /api/audit 曾经**匿名可读全站审计**,已删除
const e = await req(B, 'GET', '/api/audit');
ok('旧的裸奔 /api/audit 已不存在 → 404', e.status === 404, `status=${e.status}`);
const f = await req(B, 'GET', '/api/audit', { token: TOKEN });
ok('带管理口令也拿不到(它已迁到 /api/admin/audit)', f.status === 404, `status=${f.status}`);
const g = await req(B, 'GET', '/admin/', { raw: true });
ok('管理页可访问', g.status === 200, `status=${g.status}`);
ok('页面带 X-Robots-Tag: noindex', String(g.headers.get('x-robots-tag')).includes('noindex'));
ok('页面带 Cache-Control: no-store', String(g.headers.get('cache-control')).includes('no-store'));
}
// ═══════════════ ④ 造数据:注册一个账号 ═══════════════
const stamp = Date.now().toString(36);
const email = `admin-t-${stamp}@example.com`;
const pass1 = 'Test-pass-9f3a';
let accountId = null;
{
const r = await req(B, 'POST', '/api/accounts/register', {
body: { email, secret: pass1, display_name: `运营测试-${stamp}` },
});
accountId = r.body && r.body.account ? r.body.account.user_id : null;
ok('测试账号注册成功', r.status < 300 && !!accountId, `status=${r.status} ${JSON.stringify(r.body).slice(0, 100)}`);
}
// ═══════════════ ⑤ 读接口口径 ═══════════════
console.log('\n【4】读接口');
{
const o = await req(B, 'GET', '/api/admin/overview', { token: TOKEN });
ok('overview → 200', o.status === 200);
ok('overview 账号数为数字', typeof o.body.overview?.accounts === 'number');
ok('overview 含在线判定(known 为布尔)', typeof o.body.presence?.known === 'boolean');
ok('overview 含存储表清单', Array.isArray(o.body.storage?.tables));
const a = await req(B, 'GET', '/api/admin/accounts?limit=2', { token: TOKEN });
ok('accounts → 200 且带分页字段',
a.status === 200 && typeof a.body.total === 'number' && Array.isArray(a.body.accounts));
ok('账号行不含 secret_hash', !JSON.stringify(a.body).includes('secret_hash'));
// 分页夹紧:limit=99999 不该真的去查 10 万行
const b = await req(B, 'GET', '/api/admin/accounts?limit=99999', { token: TOKEN });
ok('limit 被夹到上限 200', b.body.limit === 200, `limit=${b.body.limit}`);
const c = await req(B, 'GET', '/api/admin/accounts?offset=999999', { token: TOKEN });
ok('offset 越界返回空列表(不是报错)',
c.status === 200 && Array.isArray(c.body.accounts) && c.body.accounts.length === 0);
const d = await req(B, 'GET', `/api/admin/accounts?q=${encodeURIComponent(stamp)}`, { token: TOKEN });
ok('按邮箱片段搜索能命中', d.body.accounts.some((x) => x.email === email),
`命中 ${d.body.accounts.length} 条`);
const e = await req(B, 'GET', '/api/admin/accounts?status=unverified', { token: TOKEN });
ok('按状态筛选(未验证)生效',
e.body.accounts.every((x) => x.email_verified === false));
// SQL 注入:排序键是唯一无法参数化的位置,必须白名单
const f = await req(B, 'GET', '/api/admin/accounts?sort=' + encodeURIComponent('id; DROP TABLE users'), { token: TOKEN });
ok('非法排序键被忽略(不拼进 SQL)', f.status === 200, `status=${f.status}`);
const dv = await req(B, 'GET', '/api/admin/devices?limit=3', { token: TOKEN });
ok('devices → 200 且带 presence_known', dv.status === 200 && typeof dv.body.presence_known === 'boolean');
ok('设备行不含令牌哈希',
!JSON.stringify(dv.body).includes('token_hash'));
const mc = await req(B, 'GET', '/api/admin/metric-columns', { token: TOKEN });
ok('metric-columns 是**数组**(对象会让前端 forEach 抛错)',
Array.isArray(mc.body.columns), typeof mc.body.columns);
ok('列清单含 cpu_usage', (mc.body.columns || []).includes('cpu_usage'));
const badCol = await req(B, 'GET',
'/api/admin/metrics/x?column=' + encodeURIComponent('1); DROP TABLE users;--'),
{ token: TOKEN });
ok('非法指标列 → 400', badCol.status === 400, `status=${badCol.status}`);
const badRange = await req(B, 'GET', '/api/admin/metrics/x?column=cpu_usage&from=2000&to=1000', { token: TOKEN });
ok('时间区间反了 → 400', badRange.status === 400, `status=${badRange.status}`);
const st = await req(B, 'GET', '/api/admin/storage', { token: TOKEN });
ok('storage → 200', st.status === 200);
ok('storage 标明是否 TimescaleDB', typeof st.body.timescale === 'boolean');
ok('storage 的 timescale 布尔与 chunks 数一致',
!st.body.timescale || st.body.chunks > 0, `timescale=${st.body.timescale} chunks=${st.body.chunks}`);
const dm = (st.body.tables || []).find((t) => t.name === 'device_metrics');
ok('storage 含 device_metrics', !!dm);
if (dm) {
ok('超表大小已含分片(不是父表那几十 KB)',
!dm.hypertable || dm.bytes > 1024 * 1024,
`bytes=${dm.bytes}`);
ok('超表行数不是 0(0 会让人以为数据丢了)',
!dm.hypertable || dm.rows_est > 0, `rows=${dm.rows_est}`);
}
}
// ═══════════════ ⑥ 详情:绝不回显凭据 ═══════════════
console.log('\n【5】详情页字段');
{
// 先给账号造一个会话(登录),这样详情里才有会话行可查
const lg = await req(B, 'POST', '/api/accounts/login', { body: { email, secret: pass1 } });
ok('测试账号登录成功(用于制造会话)', lg.status < 300, `status=${lg.status}`);
const d = await req(B, 'GET', `/api/admin/accounts/${accountId}`, { token: TOKEN });
ok('账号详情 → 200', d.status === 200);
const raw = JSON.stringify(d.body);
ok('详情不含 secret_hash', !raw.includes('secret_hash'));
ok('详情不含 secret_salt', !raw.includes('secret_salt'));
ok('详情不含完整会话令牌(只给 12 位前缀)', !raw.includes('token_hash'));
ok('会话前缀长度为 12', (d.body.sessions || []).every((s) => s.session_id.length === 12),
JSON.stringify((d.body.sessions || []).map((s) => s.session_id)));
ok('详情带自有设备列表', Array.isArray(d.body.devices));
ok('详情带审计', Array.isArray(d.body.audit));
const nf = await req(B, 'GET', '/api/admin/accounts/99999999', { token: TOKEN });
ok('不存在的账号 → 404', nf.status === 404, `status=${nf.status}`);
}
// ═══════════════ ⑦ 写动作 + 审计留痕 ═══════════════
console.log('\n【6】写动作与审计');
{
const p1 = await req(B, 'POST', `/api/admin/accounts/${accountId}/plan`, { token: TOKEN, body: { plan: 'pro' } });
ok('改档位 → 200', p1.status === 200, `status=${p1.status}`);
ok('返回改前/改后(before 不能等于 after)',
p1.body.before === 'free' && p1.body.after === 'pro',
JSON.stringify(p1.body));
const bad = await req(B, 'POST', `/api/admin/accounts/${accountId}/plan`, { token: TOKEN, body: { plan: 'vip' } });
ok('非法档位 → 400', bad.status === 400, `status=${bad.status}`);
// 造一个有效会话,再封禁,验证会话真的被吊销
await req(B, 'POST', '/api/accounts/login', { body: { email, secret: pass1 } });
const pre = await req(B, 'GET', `/api/admin/accounts/${accountId}`, { token: TOKEN });
const activeBefore = (pre.body.sessions || []).filter((s) => !s.revoked).length;
ok('封禁前存在有效会话', activeBefore > 0, `有效 ${activeBefore}`);
const dis = await req(B, 'POST', `/api/admin/accounts/${accountId}/disabled`, { token: TOKEN, body: { disabled: true } });
ok('封禁 → 200', dis.status === 200, `status=${dis.status}`);
ok('封禁同时吊销了会话(否则封了还能用)', dis.body.sessions_revoked > 0,
`revoked=${dis.body.sessions_revoked}`);
const post = await req(B, 'GET', `/api/admin/accounts/${accountId}`, { token: TOKEN });
ok('封禁后详情里已无有效会话',
(post.body.sessions || []).every((s) => s.revoked));
// 被封的账号必须登录不了(这是"封禁"的定义,不是可选项)
const lg2 = await req(B, 'POST', '/api/accounts/login', { body: { email, secret: pass1 } });
ok('被封禁的账号登录被拒', lg2.status >= 400, `status=${lg2.status}`);
const en = await req(B, 'POST', `/api/admin/accounts/${accountId}/disabled`, { token: TOKEN, body: { disabled: false } });
ok('解封 → 200', en.status === 200, `status=${en.status}`);
const lg3 = await req(B, 'POST', '/api/accounts/login', { body: { email, secret: pass1 } });
ok('解封后可以正常登录', lg3.status < 300, `status=${lg3.status}`);
const vf = await req(B, 'POST', `/api/admin/accounts/${accountId}/verify`, { token: TOKEN, body: {} });
ok('人工标记邮箱已验证 → 200', vf.status === 200 && vf.body.email_verified_at > 0,
JSON.stringify(vf.body));
const kk = await req(B, 'POST', `/api/admin/accounts/${accountId}/kick`, { token: TOKEN, body: {} });
ok('踢下线 → 200 且确有会话被吊销', kk.status === 200 && kk.body.sessions_revoked > 0,
`revoked=${kk.body.sessions_revoked}`);
// 审计:每个写动作都要留痕
const au = await req(B, 'GET', '/api/admin/audit?event=admin_&limit=50', { token: TOKEN });
const events = (au.body.entries || []).map((e) => e.event);
for (const want of ['admin_plan_changed', 'admin_account_disabled',
'admin_account_enabled', 'admin_email_forced_verified',
'admin_sessions_kicked']) {
ok(`审计里有 ${want}`, events.includes(want), `实得 ${events.slice(0, 8).join(',')}`);
}
const planEv = (au.body.entries || []).find((e) => e.event === 'admin_plan_changed');
ok('审计记录了档位前后值',
planEv && planEv.detail && planEv.detail.from === 'free' && planEv.detail.to === 'pro',
JSON.stringify(planEv && planEv.detail));
ok('审计记录了操作者',
planEv && planEv.detail && planEv.detail.operator === '测试运营');
}
// ═══════════════ ⑧ CSV:BOM + 公式注入 ═══════════════
console.log('\n【7】CSV 导出');
{
// 把显示名改成以 = 开头的公式串:Excel / Numbers 打开会**执行**它
const { PgRepository } = require('../server/shared/repository');
const repo = new PgRepository(DB_URL);
await repo.pool.query('UPDATE users SET display_name = $1 WHERE email = $2',
['=1+1', email]);
const csvBuf = await req(B, 'GET', '/api/admin/export/accounts', { token: TOKEN, bytes: true });
ok('导出 → 200', csvBuf.status === 200, `status=${csvBuf.status}`);
// 直接比前三字节 EF BB BF:这才是"文件里有 BOM"的证据
ok('带 UTF-8 BOM(否则 Excel 里中文是乱码)',
csvBuf.buf[0] === 0xEF && csvBuf.buf[1] === 0xBB && csvBuf.buf[2] === 0xBF,
`前三字节 ${[...csvBuf.buf.slice(0, 3)].map((b) => b.toString(16)).join(' ')}`);
const csv = { text: csvBuf.buf.toString('utf8') };
const firstLine = csv.text.replace(/^\ufeff/, '').split('\r\n')[0];
ok('表头首列是 account_id', firstLine.startsWith('account_id,'), firstLine.slice(0, 60));
// 关键断言:公式注入被挡住(前导单引号)
const line = csv.text.split('\r\n').find((l) => l.includes(email));
ok('导出行存在', !!line);
if (line) {
ok('以 = 开头的字段被加前导单引号(防 Excel 公式注入)',
line.includes("'=1+1"), line.slice(0, 200));
ok('未经处置的裸公式不出现', !/[,]=1\+1/.test(line), line.slice(0, 200));
}
const ev = await req(B, 'GET', '/api/admin/audit?event=admin_export&limit=5', { token: TOKEN });
ok('导出动作本身也留审计(带走的可是全站邮箱)',
(ev.body.entries || []).some((e) => e.event === 'admin_export'));
const bad = await req(B, 'GET', '/api/admin/export/nope', { token: TOKEN });
ok('未知导出类型 → 404', bad.status === 404, `status=${bad.status}`);
await repo.close();
}
// ═══════════════ ⑨ IP 白名单 ═══════════════
console.log('\n【8】ADMIN_ALLOW_IPS');
{
const { base } = await startBiz({ ADMIN_TOKEN: TOKEN, ADMIN_ALLOW_IPS: '203.0.113.9' });
const a = await req(base, 'GET', '/api/admin/ping', { token: TOKEN });
ok('来源不在白名单 → 404(连"口令错了"都不说)', a.status === 404, `status=${a.status}`);
const b = await req(base, 'GET', '/admin/', { raw: true });
ok('页面同样 404', b.status === 404, `status=${b.status}`);
}
// ═══════════════ ⑩ 暴力猜口令有上限 ═══════════════
console.log('\n【9】失败次数上限');
{
const { base } = await startBiz({ ADMIN_TOKEN: TOKEN, ADMIN_FAIL_LIMIT: '5' });
let lastCode = 0;
for (let i = 0; i < 8; i++) {
const r = await req(base, 'GET', '/api/admin/ping', { token: 'guess-' + i + '-' + 'y'.repeat(28) });
lastCode = r.status;
}
ok('连错达到上限后返回 429(不是无限期地让他继续猜)', lastCode === 429, `最后一次 status=${lastCode}`);
// 上限只该锁"错的人":正确的口令在同一 IP 上也应该被锁,
// 这是刻意取舍(共享 IP 下会误伤,但管理端本就是单人内网工具)。
const right = await req(base, 'GET', '/api/admin/ping', { token: TOKEN });
ok('锁定期间正确口令同样被挡(安全优先于便利)', right.status === 429, `status=${right.status}`);
}
// ── 汇总 ──
console.log('\n' + '='.repeat(60));
console.log(`测试通过: ${pass} 失败: ${fail}`);
console.log('='.repeat(60));
stopAll();
if (fail > 0) {
console.log('\n失败项:');
for (const f of failures) console.log(' - ' + f);
process.exit(1);
}
}
process.on('exit', stopAll);
process.on('SIGINT', () => { stopAll(); process.exit(130); });
main().catch((e) => {
console.error('[fatal]', e);
stopAll();
process.exit(1);
});