## 做了什么(顺序有意义) 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` 未编译)。
464 lines
23 KiB
JavaScript
464 lines
23 KiB
JavaScript
'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);
|
||
});
|