Files
inkwatch/tests/setup-test.js
T
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

1034 lines
52 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.
'use strict';
/**
* 安装向导 —— 「这台实例归谁管」只能确定一次。
*
* ── 为什么这套测试的重点不是"能用"而是"不能被人抢" ──────────────────
* Web 安装向导的行业经典缺陷是「谁先访问谁就是管理员」。任何把 /setup
* 敞在公网上的实现,本质都是把管理员账号挂在拍卖台上,差别只是被人
* 发现得多快。所以下面几乎每一段都在验证**某一条闸真的关着**:
*
* 【1】没设引导令牌 → /setup 与 API 全部 404(不是 401)
* 【2】令牌短于 32 字符 → 与"没设"完全等价(半个门比没门更危险)
* 【3】未初始化 → 业务端点 503,只放行 /health 与 /setup
* 【4】令牌错 → 404,且连错 N 次后**正确令牌也被挡**(429)
* 【5】口令/配置非法 → 400,且**库必须原封不动**(不能做了一半)
* 【6】成功一次之后 → 向导永久消失,**进程重启也不复活**
* 【7】两个 complete 并发 → 只有一个成功,审计只写一条
* 【8】存量库升级(adopted)→ 不断服;已存在的邮箱只提升权限、**不改口令**
*
* ── 为什么每个场景自己建一个临时库 ─────────────────────────────────
* 本套件要验证 `pending`(全新库)与 `adopted`(旧库升级)两种形态,
* 用现有开发库做不到:开发库里已经有几十个账号,migrate.sql 会把它判成
* `adopted`,于是"未初始化闸门"那几段永远跑不到。
* 就地改现有库的 bootstrap_state 也不行 —— 中途崩溃会把开发库留在
* `pending`,之后所有业务端点 503,属于"测试把环境搞坏"。
*
* 故:每个场景 `CREATE DATABASE` 一个临时库、跑真实的 init.sql/migrate.sql、
* 结束后 DROP。顺带把**全新库这条路径**本身也测了 —— 历史教训:
* 只在已有卷上验证过的建表顺序,首次部署时直接把容器启崩(见 MEMORY 首装路径那条)。
*
* ── 前置 ───────────────────────────────────────────────────────────
* PG(能 CREATE DATABASE)+ Redis。业务服务由本套件自己拉起(随机端口 +
* 独立 outbox + 独立库)。缺依赖时打印 [skip] 并退出 0 —— 不冒充通过,也不记失败。
*
* 运行:node tests/setup-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 BASE_DB = process.env.DATABASE_URL
|| 'postgres://inkwatch:inkwatch_dev_only@127.0.0.1:5432/inkwatch';
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
// pg 装在 server/node_modules 下(业务服务自己的依赖),测试从那里借。
// 拿不到就当作"环境缺依赖",而不是让 require 直接崩。
let pg = null;
try { pg = require(path.join(ROOT, 'server', 'node_modules', 'pg')); } catch { pg = null; }
let hashSecret = null;
// 口径统一在**仓库根**的 shared/accounts.js(server/shared 只有仓储与身份判定)
try { ({ hashSecret } = require(path.join(ROOT, 'shared', 'accounts'))); } catch { /* 见 main 里的 skip */ }
/** 引导令牌:>= 32 字符(setup.js 的 MIN_TOKEN_LEN)。故意可读,泄漏时一眼认出来。 */
const TOKEN = 'setup-test-token-0123456789abcdefgh';
/** 30 字符 —— 差一点点,用来验证"短了就等于没设" */
const SHORT_TOKEN = 'setup-test-token-too-short-31c';
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 = [];
async function stopAll() {
await Promise.all(children.map((c) => stopProc(c)));
children.length = 0;
}
function stopProc(p) {
if (!p || p.exitCode !== null) return Promise.resolve();
return new Promise((resolve) => {
const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* 已退出 */ } resolve(); }, 3000);
p.once('exit', () => { clearTimeout(t); resolve(); });
try { p.kill('SIGTERM'); } catch { clearTimeout(t); resolve(); }
});
}
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 = 25000) {
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;
}
const dbUrl = (name) => {
const u = new URL(BASE_DB);
u.pathname = '/' + name;
return u.toString();
};
/**
* 起一个隔离实例。
*
* 每个实例有自己的端口、库、outbox。`INSTANCE_SETUP_TOKEN` 与
* `ADMIN_TOKEN` 都是**启动期读的**,同一进程内改不了,只能换进程。
*/
async function startBiz(dbName, extraEnv = {}) {
const port = await freePort();
const outbox = path.join(os.tmpdir(), `inkwatch-setup-${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),
DATABASE_URL: dbUrl(dbName),
REDIS_URL,
MAIL_OUTBOX_PATH: outbox,
TRUST_PROXY_HOPS: '0',
// 全部显式清空。父 shell 里带进来的开关会让"库里改了却没生效"
// 变成假象,而这类假象最难查(配置明明改了,行为没变)。
INSTANCE_NAME: '', PUBLIC_URL: '', ALLOW_OPEN_REGISTER: '',
REQUIRE_AUTH: '', REQUIRE_DEVICE_TOKEN: '', ENFORCE_DEVICE_QUOTA: '',
FREE_DEVICE_LIMIT: '', RELAY_MAX_BYTES: '', MAIL_MODE: '',
MAIL_FROM: '', AUDIT_RETENTION_DAYS: '',
// 管理端只留会话通道:这样才能证明"没有 ADMIN_TOKEN,靠库里的
// is_admin 也能进管理端"(admin.js 的 available() 双通道)
ADMIN_TOKEN: '',
INSTANCE_SETUP_TOKEN: '',
...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}`;
const up = await waitHealth(base);
return {
base,
proc: p,
outbox, // 发信落点:本套件要验证"配置真的被 mailer 读到了"
log: () => log,
up,
stop: async () => { await stopProc(p); },
};
}
/** 发请求;token 传 null 表示不带 Authorization */
async function req(base, method, p, { token, body, raw } = {}) {
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 };
let json = null;
try { json = await r.json(); } catch { /* 非 JSON */ }
return { status: r.status, body: json || {}, headers: r.headers };
}
// ────────────────────────── 临时库 ──────────────────────────
async function withAdmin(fn) {
const c = new pg.Client({ connectionString: dbUrl('postgres') });
await c.connect();
try { return await fn(c); } finally { await c.end(); }
}
async function createDb(name) {
await withAdmin((c) => c.query(`CREATE DATABASE ${name}`));
}
async function dropDb(name) {
try {
await withAdmin((c) => c.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`));
} catch {
// PG < 13 没有 WITH (FORCE)
try { await withAdmin((c) => c.query(`DROP DATABASE IF EXISTS ${name}`)); } catch { /* 留着也无害 */ }
}
}
/** 读 outbox(发信落点):一行一个 JSON */
function readOutbox(file) {
try {
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => {
try { return JSON.parse(l); } catch { return null; }
}).filter(Boolean);
} catch { return []; } // 文件不存在 = 还没发过信
}
/** 跑一句 SQL 并关连接(测试里的读校验都走它) */
async function q(dbName, text, params) {
const c = new pg.Client({ connectionString: dbUrl(dbName) });
await c.connect();
try { return (await c.query(text, params)).rows; } finally { await c.end(); }
}
/**
* 把一段多语句 SQL 切成单条。
*
* ⚠️ 不能整段 `client.query(sql)` 丢过去:node-postgres 的简单查询会把
* **多条语句当成一个隐式事务**,而 init.sql 末尾的 `ALTER SYSTEM`
* 在事务块里是硬错误("ALTER SYSTEM cannot run inside a transaction block")。
* 首跑就栽在这上面。
*
* 切分必须能识别三类"看起来像分隔符但不能切"的上下文:
* · 单引号字符串(含 '' 转义)
* · 美元引用 `$inkwatch$ ... $inkwatch$`(本仓库的 DO 块用它,且**可嵌套**
* 不同标签:DO $inkwatch$ ... EXECUTE $sql$ ... $sql$ ... END $inkwatch$)
* · 行注释与块注释
*/
function splitSql(text) {
const out = [];
let buf = '';
let i = 0;
const n = text.length;
let tag = null;
while (i < n) {
const ch = text[i];
if (!tag && ch === '-' && text[i + 1] === '-') {
let e = text.indexOf('\n', i);
if (e === -1) e = n;
buf += text.slice(i, e);
i = e;
continue;
}
if (!tag && ch === '/' && text[i + 1] === '*') {
let e = text.indexOf('*/', i + 2);
e = e === -1 ? n : e + 2;
buf += text.slice(i, e);
i = e;
continue;
}
if (!tag && ch === "'") {
let j = i + 1;
while (j < n) {
if (text[j] === "'" && text[j + 1] === "'") { j += 2; continue; }
if (text[j] === "'") { j++; break; }
j++;
}
buf += text.slice(i, j);
i = j;
continue;
}
if (tag) {
if (text.startsWith(tag, i)) { buf += tag; i += tag.length; tag = null; continue; }
buf += ch; i++; continue;
}
if (ch === '$') {
const m = /^\$[A-Za-z_]*\$/.exec(text.slice(i, i + 64));
if (m) { tag = m[0]; buf += tag; i += tag.length; continue; }
buf += ch; i++; continue;
}
if (ch === ';') { buf += ch; out.push(buf); buf = ''; i++; continue; }
buf += ch; i++;
}
if (buf.trim()) out.push(buf);
return out.map((s) => s.trim()).filter((s) => s && !/^;+$/.test(s));
}
/**
* 这些语句**故意不跑**:它们改的是整个 PG 集群的配置
* (postgresql.auto.conf + pg_reload_conf),而本套件只是建一个临时库。
* 让测试去重配共享集群,是"测试把环境搞坏"的另一种形态。
*/
const SKIP_SQL = /^(ALTER\s+SYSTEM|SELECT\s+pg_reload_conf)/i;
function stripLeadingComments(s) {
return s.replace(/^(?:\s*--[^\n]*\n)+/g, '');
}
const readDeploy = (f) => fs.readFileSync(path.join(ROOT, 'deploy', f), 'utf8');
/**
* 建库并铺表结构。
* @param {string} dbName
* @param {'fresh'|'legacy'} mode
* fresh —— 跑**完整** init.sql(含尾部 `\i /migrate.sql`),等价于全新部署
* legacy —— 只跑迁移之前的部分(人为跳过 `\i`),得到"升级前的旧库"
*/
async function buildSchema(dbName, mode) {
const mig = readDeploy('migrate.sql');
let sql = readDeploy('init.sql');
sql = sql.replace(/^\\i\s+\/migrate\.sql\s*$/m, () => (mode === 'fresh'
? mig
// 用函数形式回填:直接写字符串会让 `$inkwatch$` 被当成 $1 之类的占位符
: '-- 本场景刻意不跑迁移:模拟升级前的旧库(没有 instance_config / users.is_admin)'));
const c = new pg.Client({ connectionString: dbUrl(dbName) });
await c.connect();
try {
for (const stmt of splitSql(sql)) {
if (SKIP_SQL.test(stripLeadingComments(stmt))) continue;
await c.query(stmt);
}
} finally {
await c.end();
}
}
/** 旧库升级:在 legacy 库上补一个存量用户,再跑真实的 migrate.sql */
async function upgradeLegacy(dbName, legacyEmail, legacySecret) {
const { hash, salt } = hashSecret(legacySecret);
await q(dbName,
`INSERT INTO users (email, display_name, secret_hash, secret_salt, secret_updated_at)
VALUES ($1, '存量用户', $2, $3, now())`,
[legacyEmail, hash, salt]);
const c = new pg.Client({ connectionString: dbUrl(dbName) });
await c.connect();
try {
for (const stmt of splitSql(readDeploy('migrate.sql'))) {
if (SKIP_SQL.test(stripLeadingComments(stmt))) continue;
await c.query(stmt);
}
} finally {
await c.end();
}
}
// ────────────────────────── 主体 ──────────────────────────
async function main() {
// ── 依赖自检:别把"没起 PG"报成"功能坏了" ──
if (!pg || !hashSecret) {
console.log('\n[skip] 缺依赖(server/node_modules/pg 或 shared/accounts.js 不可用),跳过。');
process.exit(0);
}
try {
await withAdmin((c) => c.query('SELECT 1'));
} catch (e) {
console.log('\n[skip] 连不上 PostgreSQL:' + e.message);
process.exit(0);
}
const tag = Date.now().toString(36);
const DB_FRESH = `inkwatch_setup_fresh_${tag}`; // 全新库 → pending(成功路径)
const DB_VALID = `inkwatch_setup_valid_${tag}`; // 校验矩阵专用(**不能**被成功路径污染)
const DB_LEGACY = `inkwatch_setup_legacy_${tag}`; // 旧库升级 → adopted
const DB_RACE = `inkwatch_setup_race_${tag}`; // 并发对决
const dbs = [DB_FRESH, DB_VALID, DB_LEGACY, DB_RACE];
const adminEmail = `setup-admin-${tag}@example.com`;
// 夹具口令一律**低熵全小写**:隐私扫描把「变量名像 secret + 值有大小写数字混排」
// 判成疑似真实密钥。顺手也说明它不是任何真实凭据。
const adminSecret = 'test-admin-pw';
const normalsecret = 'test-normal-pw';
try {
// ═════════ 场景一:全新库(pending)+ 令牌未配置 ═════════
console.log('\n【准备】三个临时库(全新 / 旧库 / 并发)');
for (const d of dbs) {
await dropDb(d); // 上一轮异常退出的残留
await createDb(d);
}
await buildSchema(DB_FRESH, 'fresh');
await buildSchema(DB_VALID, 'fresh');
await buildSchema(DB_RACE, 'fresh');
await buildSchema(DB_LEGACY, 'legacy');
{
const r = await q(DB_FRESH, 'SELECT bootstrap_state FROM instance_config');
ok('全新库落成 pending(0 个账号)', r[0] && r[0].bootstrap_state === 'pending',
JSON.stringify(r[0]));
const l = await q(DB_LEGACY, 'SELECT to_regclass(\'instance_config\') AS t');
ok('旧库确实没有 instance_config 表(模拟升级前)', l[0].t === null, String(l[0].t));
}
// ── 【1】引导令牌未设置:向导整体不存在 ──
console.log('\n【1】未配置 INSTANCE_SETUP_TOKEN → API 一律 404');
const A = await startBiz(DB_FRESH);
if (!A.up) {
console.log(' [skip] 实例起不来,日志尾部:');
console.log(A.log().trim().split('\n').slice(-8).map((l) => ' ' + l).join('\n'));
await stopAll();
process.exit(0);
}
{
const h = await req(A.base, 'GET', '/health');
ok('/health 如实报 pending(容器健康检查与部署验收要用)',
h.status === 200 && h.body.bootstrap_state === 'pending',
`status=${h.status} bootstrap=${h.body.bootstrap_state}`);
// 页面本身不含秘密,可以打开 —— 它要告诉运维"该去设哪个环境变量"
const pg1 = await req(A.base, 'GET', '/setup', { raw: true });
ok('/setup 页面可打开(不含任何秘密)', pg1.status === 200, `status=${pg1.status}`);
ok(' 带 Cache-Control: no-store', String(pg1.headers.get('cache-control')).includes('no-store'));
ok(' 带 X-Robots-Tag: noindex(别被搜索引擎收录)',
String(pg1.headers.get('x-robots-tag')).includes('noindex'));
const st = await req(A.base, 'GET', '/api/setup/state');
ok('/api/setup/state 如实说"令牌没配"',
st.status === 200 && st.body.token_configured === false,
`token_configured=${st.body.token_configured}`);
const pr = await req(A.base, 'POST', '/api/setup/probe');
ok('probe 无令牌 → 404', pr.status === 404, `status=${pr.status}`);
ok(' 错误码是 E_NOT_FOUND 而不是 E_TOKEN_REQUIRED(不宣布"这里有个向导")',
pr.body.error === 'E_NOT_FOUND', String(pr.body.error));
ok(' 不返回任何 message("没配"与"配错"必须完全不可区分)',
pr.body.message === undefined, JSON.stringify(pr.body));
const cp = await req(A.base, 'POST', '/api/setup/complete', {
body: { admin: { email: adminEmail, secret: adminSecret } },
});
ok('complete 无令牌 → 404', cp.status === 404, `status=${cp.status}`);
// 随便编一个令牌也一样:没配置就是没配置,不是"你猜错了"
const fake = await req(A.base, 'POST', '/api/setup/probe', { token: TOKEN });
ok('拿一个格式正确的令牌来也一样 404', fake.status === 404, `status=${fake.status}`);
}
await A.stop();
// ── 【2】令牌太短:与"没设"等价 ──
console.log('\n【2】INSTANCE_SETUP_TOKEN 短于 32 字符 → 视为未设置');
const B = await startBiz(DB_FRESH, { INSTANCE_SETUP_TOKEN: SHORT_TOKEN });
{
const pr = await req(B.base, 'POST', '/api/setup/probe', { token: SHORT_TOKEN });
ok('用这串短令牌自己也进不去("看起来设过了"比没设更危险)',
pr.status === 404, `status=${pr.status}`);
const st = await req(B.base, 'GET', '/api/setup/state');
ok('state 也报 token_configured:false(页面据此提示运维改环境变量)',
st.status === 200 && st.body.token_configured === false,
`token_configured=${st.body.token_configured}`);
}
// ── 【3】未初始化闸门 ──
console.log('\n【3】未初始化(pending)→ 业务端点 503,只放行 health / setup');
{
for (const p of ['/api/accounts/me', '/api/devices/register', '/admin/']) {
const r = await req(B.base, 'GET', p);
ok(`${p} → 503`, r.status === 503, `status=${r.status}`);
if (p === '/api/accounts/me') {
ok(' 错误码 E_NOT_INITIALIZED', r.body.error === 'E_NOT_INITIALIZED', String(r.body.error));
ok(' 提示语指明该去 /setup', String(r.body.message || '').includes('/setup'));
}
}
const lo = await req(B.base, 'POST', '/api/accounts/login',
{ body: { email: adminEmail, secret: adminSecret } });
ok('登录也是 503(不是 401:别让人以为"口令错了")', lo.status === 503, `status=${lo.status}`);
const h = await req(B.base, 'GET', '/health');
ok('/health 放行', h.status === 200, `status=${h.status}`);
const st = await req(B.base, 'GET', '/api/setup/state');
ok('/api/setup/state 放行', st.status === 200, `status=${st.status}`);
}
await B.stop();
// ── 【4】失败闸门:错令牌 N 次后,对的令牌也进不来 ──
console.log('\n【4】令牌错误 → 404;连错达上限 → 429(对的令牌同样被挡)');
const C = await startBiz(DB_RACE, { INSTANCE_SETUP_TOKEN: TOKEN, SETUP_FAIL_LIMIT: '3' });
{
const wrong = TOKEN.slice(0, -1) + 'X';
const r1 = await req(C.base, 'POST', '/api/setup/probe', { token: wrong });
ok('错令牌 → 404', r1.status === 404, `status=${r1.status}`);
const none = await req(C.base, 'POST', '/api/setup/probe');
ok('响应体与"没配令牌"完全一致(不可区分 → 不可探测)',
JSON.stringify(r1.body) === JSON.stringify(none.body),
`${JSON.stringify(r1.body)} vs ${JSON.stringify(none.body)}`);
await req(C.base, 'POST', '/api/setup/probe', { token: wrong });
await req(C.base, 'POST', '/api/setup/probe', { token: wrong }); // 第 3 次
const over = await req(C.base, 'POST', '/api/setup/probe', { token: wrong });
ok('第 4 次 → 429', over.status === 429, `status=${over.status}`);
ok(' 错误码 E_TOO_MANY_ATTEMPTS', over.body.error === 'E_TOO_MANY_ATTEMPTS', String(over.body.error));
ok(' 给出 retry_after_sec(别让人无限重试)',
Number(over.body.retry_after_sec) > 0, String(over.body.retry_after_sec));
// 关键:闸门在令牌校验**之前**,否则"猜对了就绕过限流"
const good = await req(C.base, 'POST', '/api/setup/probe', { token: TOKEN });
ok('此时用**正确**令牌也是 429(限流不能被"猜对了"绕过)',
good.status === 429, `status=${good.status}`);
const remain = await q(DB_RACE, 'SELECT bootstrap_state FROM instance_config');
ok('反复失败不改变库状态(失败必须是"什么都没发生")',
remain[0].bootstrap_state === 'pending', remain[0].bootstrap_state);
}
await C.stop();
// ── 【5】参数校验 ──
// 这一段**单独一个库**:万一某条"该被拒"的用例被接受,实例会被初始化,
// 后面每一条都会变成 404 噪音 —— 首跑就吃过这个亏,20 条假红全在追一个真因。
console.log('\n【5】口令与配置校验:非法即 400,且库原封不动');
const V = await startBiz(DB_VALID, { INSTANCE_SETUP_TOKEN: TOKEN, FREE_DEVICE_LIMIT: '4' });
{
const pr = await req(V.base, 'POST', '/api/setup/probe', { token: TOKEN });
ok('probe 200', pr.status === 200, `status=${pr.status}`);
ok(' 报 state=pending', pr.body.state === 'pending', String(pr.body.state));
ok(' 报当前账号数 0', pr.body.existing && pr.body.existing.accounts === 0,
JSON.stringify(pr.body.existing));
// PG 的 BIGINT 经 node-postgres 出来是**字符串**("1048576")。
// 直接透传给前端会让 `relay_max_bytes > 1000` 这类比较变成字符串比较。
ok(' preview.relay_max_bytes 是 number 而不是字符串(BIGINT 坑)',
typeof pr.body.preview?.relay_max_bytes === 'number',
typeof pr.body.preview?.relay_max_bytes + ' = ' + pr.body.preview?.relay_max_bytes);
ok(' ENV 覆盖的字段被标成来源 env(前端据此显示"被锁")',
pr.body.preview && pr.body.preview.free_device_limit === 4,
JSON.stringify(pr.body.preview?.free_device_limit));
}
{
// 弱口令用例里"含邮箱名"一条必须用**短的**本地部分才测得出效果:
// checkAdminSecret 比的是完整 local part(`ops@x.com` → "ops"),
// 而本套件的 adminEmail 带时间戳尾巴,用它永远不可能被这条规则命中。
const weakEmail = 'ops@example.com';
const cases = [
['口令太短(< 12)', { email: adminEmail, secret: 'Ab3-x9' }, 'E_WEAK_SECRET'],
['口令纯数字', { email: adminEmail, secret: '173940285617' }, 'E_WEAK_SECRET'],
['口令含邮箱名', { email: weakEmail, secret: 'ops-pass-word' }, 'E_WEAK_SECRET'],
['口令在弱口令表里', { email: adminEmail, secret: 'administrator' }, 'E_WEAK_SECRET'],
['邮箱格式错', { email: 'not-an-email', secret: adminSecret }, 'E_BAD_EMAIL'],
];
for (const [label, admin, want] of cases) {
const r = await req(V.base, 'POST', '/api/setup/complete', { token: TOKEN, body: { admin } });
ok(`${label} → 400 ${want}`, r.status === 400 && r.body.error === want,
`status=${r.status} error=${r.body.error}`);
if (r.status === 200) {
// 一旦"该被拒的"被接受,后面每一条都会变成 404 噪音 —— 立刻停下说清楚
console.log(' ⚠️ 有一条非法用例被接受,后续用例已无意义,终止本段');
break;
}
}
// 配置非法:值域由 instance_config 的 CHECK 与 validatePatch 双层守
const badCfg = await req(V.base, 'POST', '/api/setup/complete', {
token: TOKEN,
body: { admin: { email: adminEmail, secret: adminSecret }, config: { free_device_limit: -5 } },
});
ok('配置越界(free_device_limit=-5)→ 400 E_BAD_CONFIG',
badCfg.status === 400 && badCfg.body.error === 'E_BAD_CONFIG',
`status=${badCfg.status} error=${badCfg.body.error}`);
const after = await q(DB_VALID,
'SELECT (SELECT bootstrap_state FROM instance_config) AS st, (SELECT count(*)::int FROM users) AS n');
ok('全部失败之后:状态仍 pending、账号仍是 0(失败必须整体回滚)',
after[0].st === 'pending' && after[0].n === 0,
`state=${after[0].st} users=${after[0].n}`);
}
await V.stop();
// ── 【6】成功初始化 ──
console.log('\n【6】初始化成功 → 只成功一次,且重启后不复活');
let adminSession = null;
const D = await startBiz(DB_FRESH, { INSTANCE_SETUP_TOKEN: TOKEN, FREE_DEVICE_LIMIT: '4' });
{
const r = await req(D.base, 'POST', '/api/setup/complete', {
token: TOKEN,
body: {
admin: { email: adminEmail, secret: adminSecret, display_name: '运维管理员' },
config: { instance_name: '测试实例', allow_open_register: true },
},
});
ok('complete → 200', r.status === 200, `status=${r.status}`);
ok(' promoted=false(这是新建的管理员)', r.body.admin?.promoted === false,
JSON.stringify(r.body.admin));
ok(' 响应里不含口令', !JSON.stringify(r.body).includes(adminSecret));
ok(' 指向 /admin/', String(r.body.next || '').includes('/admin'));
const rows = await q(DB_FRESH,
`SELECT c.instance_name, c.bootstrap_state, c.initialized_by, c.initialized_at,
c.allow_open_register,
u.id, u.is_admin, u.email_verified_at, u.display_name
FROM instance_config c CROSS JOIN users u WHERE c.id = 1`);
const row = rows[0];
ok('库里状态变 ready', row.bootstrap_state === 'ready', row.bootstrap_state);
ok('初始化执行者已记录(审计要看是谁开的这台)',
!!row.initialized_by && row.initialized_at !== null, String(row.initialized_by));
ok('配置项落库(instance_name)', row.instance_name === '测试实例', row.instance_name);
ok('配置项落库(allow_open_register)', row.allow_open_register === true,
String(row.allow_open_register));
ok('管理员 is_admin=true', row.is_admin === true, String(row.is_admin));
// 管理员邮箱视为已验证:否则"未验证只能绑 1 台"会把运营者自己拦在门外
ok('管理员邮箱已被标记为已验证', row.email_verified_at !== null,
String(row.email_verified_at));
const audit = await q(DB_FRESH,
`SELECT detail FROM audit_log WHERE event = 'instance_initialized'`);
ok('写了且只写了一条 instance_initialized 审计', audit.length === 1, `${audit.length} 条`);
ok(' 审计明细里不含口令',
audit[0] && !JSON.stringify(audit[0].detail).includes(adminSecret));
// 立刻生效:不做这一步的话,3 秒轮询窗口内业务端点仍是 503,
// 运维会看到"初始化成功但进不去",然后反复点。
const me = await req(D.base, 'GET', '/api/accounts/me');
ok('紧接着访问业务端点:401(不再是 503)—— 配置变更同进程立刻生效',
me.status === 401, `status=${me.status}`);
const sp = await req(D.base, 'GET', '/setup', { raw: true });
ok('/setup → 404(完成后向导永久消失)', sp.status === 404, `status=${sp.status}`);
const st = await req(D.base, 'GET', '/api/setup/state');
ok('/api/setup/state → 404', st.status === 404, `status=${st.status}`);
const pr = await req(D.base, 'POST', '/api/setup/probe', { token: TOKEN });
ok('拿着正确令牌 probe → 404', pr.status === 404, `status=${pr.status}`);
}
// 重放与重启:判据必须是库里的状态,不是内存变量
{
const again = await req(D.base, 'POST', '/api/setup/complete', {
token: TOKEN,
body: { admin: { email: `second-${tag}@example.com`, secret: adminSecret } },
});
ok('重放 complete → 404(不是 409:向导已消失,不再"讨论"它)',
again.status === 404, `status=${again.status}`);
await D.stop();
const D2 = await startBiz(DB_FRESH, { INSTANCE_SETUP_TOKEN: TOKEN, FREE_DEVICE_LIMIT: '4' });
const sp = await req(D2.base, 'GET', '/setup', { raw: true });
ok('**进程重启后 /setup 仍是 404**(判据在库,内存变量会让向导复活)',
sp.status === 404, `status=${sp.status}`);
const body2 = await req(D2.base, 'GET', '/health');
ok('/health 报 ready', body2.body.bootstrap_state === 'ready',
String(body2.body.bootstrap_state));
// ── 管理员双通道:没有 ADMIN_TOKEN,靠库里的 is_admin 进管理端 ──
console.log('\n【6b】管理员身份:库里的 is_admin 即可进管理端(无 ADMIN_TOKEN)');
const login = await req(D2.base, 'POST', '/api/accounts/login',
{ body: { email: adminEmail, secret: adminSecret } });
ok('管理员账号可登录', login.status === 200 && !!login.body.token,
`status=${login.status}`);
adminSession = login.body.token;
const ping = await req(D2.base, 'GET', '/api/admin/ping', { token: adminSession });
ok('会话进管理端 → 200', ping.status === 200, `status=${ping.status}`);
ok(' 标明来源是 session', ping.body.via === 'session', String(ping.body.via));
ok(' 操作者名字可追溯', !!ping.body.operator, String(ping.body.operator));
const page = await req(D2.base, 'GET', '/admin/', { raw: true });
ok('管理页也放行了(此前 available() 只看 ADMIN_TOKEN,会一律 404)',
page.status === 200, `status=${page.status}`);
// 普通用户:注册一个,确认它进不去
const reg = await req(D2.base, 'POST', '/api/accounts/register',
{ body: { email: `normal-${tag}@example.com`, secret: normalsecret } });
ok('普通用户可注册', reg.status === 201 && !!reg.body.token, `status=${reg.status}`);
const normalId = reg.body.account?.user_id;
const ping2 = await req(D2.base, 'GET', '/api/admin/ping', { token: reg.body.token });
ok('普通用户会话 → 401(管理端不是"登录了就能进")', ping2.status === 401,
`status=${ping2.status}`);
// 提升 / 取消 / 最后一个管理员
const grant = await req(D2.base, 'POST', `/api/admin/accounts/${normalId}/admin`,
{ token: adminSession, body: { is_admin: true } });
ok('把普通用户提成管理员 → 200', grant.status === 200 && grant.body.is_admin === true,
`status=${grant.status} is_admin=${grant.body.is_admin}`);
const admins = await req(D2.base, 'GET', '/api/admin/admins', { token: adminSession });
ok('管理员列表现在是 2 个', admins.status === 200 && admins.body.admins?.length === 2,
JSON.stringify(admins.body.admins?.length));
const revoke = await req(D2.base, 'POST', `/api/admin/accounts/${normalId}/admin`,
{ token: adminSession, body: { is_admin: false } });
ok('再取消 → 200(还剩一个,允许)', revoke.status === 200, `status=${revoke.status}`);
const last = await req(D2.base, 'POST', `/api/admin/accounts/${normalId}/admin`,
{ token: adminSession, body: { is_admin: false } });
ok('取消一个本来就不是管理员的账号 → 200(幂等)', last.status === 200,
`status=${last.status}`);
// 自我取消:只剩自己一个管理员时必须拒绝
const me = await req(D2.base, 'GET', '/api/accounts/me', { token: adminSession });
const myId = me.body.account?.user_id;
const selfRevoke = await req(D2.base, 'POST', `/api/admin/accounts/${myId}/admin`,
{ token: adminSession, body: { is_admin: false } });
ok('取消**最后一个**可用管理员 → 409 E_LAST_ADMIN(否则没人能再进管理端)',
selfRevoke.status === 409 && selfRevoke.body.error === 'E_LAST_ADMIN',
`status=${selfRevoke.status} error=${selfRevoke.body.error}`);
// ── 实例设置:库里改得动 + env 锁定的改不动 ──
console.log('\n【6c】实例设置:库是真相源,env 是显式覆盖层');
const cfgGet = await req(D2.base, 'GET', '/api/admin/instance-config', { token: adminSession });
ok('读取生效配置 → 200', cfgGet.status === 200, `status=${cfgGet.status}`);
ok(' free_device_limit 生效值来自 env(4)', cfgGet.body.config?.free_device_limit === 4,
String(cfgGet.body.config?.free_device_limit));
// 端到端:FREE_DEVICE_LIMIT=4 这个 env 要一路走到客户端看到的配额数字。
// 之前 free_device_limit 只落库没人读,界面上写"免费 3 台",改什么都不生效。
const meQ = await req(D2.base, 'GET', '/api/accounts/me', { token: adminSession });
ok('/api/accounts/me 报的 device_quota 来自实例配置(env FREE_DEVICE_LIMIT=4)',
meQ.body.device_quota === 4, String(meQ.body.device_quota));
ok(' locked 列表含 free_device_limit(界面据此显示"被环境变量锁定")',
Array.isArray(cfgGet.body.locked) && cfgGet.body.locked.includes('free_device_limit'),
JSON.stringify(cfgGet.body.locked));
const locked = await req(D2.base, 'POST', '/api/admin/instance-config',
{ token: adminSession, body: { free_device_limit: 9 } });
ok('改被 env 锁定的字段 → 409 E_ENV_LOCKED(不能静默成功)',
locked.status === 409 && locked.body.error === 'E_ENV_LOCKED',
`status=${locked.status} error=${locked.body.error}`);
ok(' 明确指出是哪几个字段', Array.isArray(locked.body.fields)
&& locked.body.fields.includes('free_device_limit'), JSON.stringify(locked.body.fields));
const changed = await req(D2.base, 'POST', '/api/admin/instance-config',
{ token: adminSession, body: { instance_name: '改过的实例名' } });
ok('改没被锁的字段 → 200', changed.status === 200, `status=${changed.status}`);
ok(' 返回实际改动的字段清单', Array.isArray(changed.body.changed)
&& changed.body.changed.includes('instance_name'), JSON.stringify(changed.body.changed));
const now = await q(DB_FRESH, 'SELECT instance_name FROM instance_config');
ok(' 库里确实变了', now[0].instance_name === '改过的实例名', now[0].instance_name);
// ── 【6d】"把最后一个管理员弄没"的三个入口 ──
//
// 降权有这道闸,封禁与自助注销**曾经都没有**。后者更隐蔽:管理员注销
// 自己之后,库里的 is_admin 还在但账号进了冷静期,`available()` 于是
// 认为"有管理员"→ 管理端继续对外提供登录页 → 却没人能进。
console.log('\n【6d】最后管理员:降权 / 封禁 / 自助注销,三条路都必须拦住');
{
const disableSelf = await req(D2.base, 'POST', `/api/admin/accounts/${myId}/disabled`,
{ token: adminSession, body: { disabled: true } });
ok('封禁**最后一个**可用管理员 → 409 E_LAST_ADMIN(曾漏掉这条)',
disableSelf.status === 409 && disableSelf.body.error === 'E_LAST_ADMIN',
`status=${disableSelf.status} error=${disableSelf.body.error}`);
const deleteSelf = await req(D2.base, 'DELETE', '/api/accounts/me',
{ token: adminSession, body: { secret: adminSecret } });
ok('管理员**注销自己**(最后一个)→ 409 E_LAST_ADMIN(曾漏掉这条)',
deleteSelf.status === 409 && deleteSelf.body.error === 'E_LAST_ADMIN',
`status=${deleteSelf.status} error=${deleteSelf.body.error}`);
const stillAlive = await q(DB_FRESH,
`SELECT deleted_at, disabled_at FROM users WHERE email = $1`, [adminEmail]);
ok(' 两次被拦之后账号原封不动(拒绝发生在写之前)',
stillAlive[0] && stillAlive[0].deleted_at === null && stillAlive[0].disabled_at === null,
JSON.stringify(stillAlive[0]));
// 有第二个管理员之后,封禁与注销都不是问题
const promote = await req(D2.base, 'POST', `/api/admin/accounts/${normalId}/admin`,
{ token: adminSession, body: { is_admin: true } });
ok('先提升第二个管理员 → 200', promote.status === 200, `status=${promote.status}`);
const disableNow = await req(D2.base, 'POST', `/api/admin/accounts/${myId}/disabled`,
{ token: adminSession, body: { disabled: true } });
ok('有了继任者再封禁 → 200', disableNow.status === 200, `status=${disableNow.status}`);
// 被封禁者的会话应被同步吊销(否则"封了还能用两小时")
const dead = await req(D2.base, 'GET', '/api/admin/ping', { token: adminSession });
ok(' 被封禁管理员的会话立刻失效 → 401', dead.status === 401, `status=${dead.status}`);
// 新管理员顶上:可用管理员仍存在 → 管理端继续可用(不是 404)
const page = await req(D2.base, 'GET', '/admin/', { raw: true });
ok(' 管理端仍可用(还有可用管理员,不该整体 404)', page.status === 200,
`status=${page.status}`);
// 现在轮到新管理员是最后一个:封禁它同样要拦
const lastOne = await req(D2.base, 'POST', `/api/admin/accounts/${normalId}/disabled`,
{ token: reg.body.token, body: { disabled: true } });
ok('换人之后,**最后一个**仍然被拦住 → 409 E_LAST_ADMIN',
lastOne.status === 409 && lastOne.body.error === 'E_LAST_ADMIN',
`status=${lastOne.status} error=${lastOne.body.error}`);
// 收尾:把原管理员解封,后面几段继续用它
const enable = await req(D2.base, 'POST', `/api/admin/accounts/${myId}/disabled`,
{ token: reg.body.token, body: { disabled: false } });
ok('解封 → 200', enable.status === 200, `status=${enable.status}`);
const relogin = await req(D2.base, 'POST', '/api/accounts/login',
{ body: { email: adminEmail, secret: adminSecret } });
ok(' 解封后可重新登录', relogin.status === 200 && !!relogin.body.token,
`status=${relogin.status}`);
adminSession = relogin.body.token;
}
// ── 【6e】发信配置真的被 mailer 读到(不是只落库) ──
//
// 这一段守的是"开关写了没人读":mail_mode/mail_from/public_url 三个字段
// 上一轮只落了库,代码里没有任何一行读它们。测试必须看**外部可观测的
// 后果**(outbox 里的行),而不是再看一遍库。
console.log('\n【6e】mail_mode / mail_from / public_url 影响真实发信行为');
{
const sendReset = () => req(D2.base, 'POST', '/api/accounts/reset/request',
{ body: { email: adminEmail } });
// 库里的默认值是 console —— 不发信、不落盘
const cfg0 = await q(DB_FRESH, 'SELECT mail_mode FROM instance_config');
ok('库里默认 mail_mode = console(安全侧默认,不是 outbox)',
cfg0[0].mail_mode === 'console', cfg0[0].mail_mode);
await sendReset();
ok('console 模式下不写 outbox(令牌不会明文落盘)',
readOutbox(D2.outbox).filter((x) => x.to === adminEmail).length === 0,
`${readOutbox(D2.outbox).length} 行`);
const setMail = await req(D2.base, 'POST', '/api/admin/instance-config', {
token: adminSession,
body: {
mail_mode: 'outbox',
mail_from: 'noreply@ink.example',
public_url: 'https://ink.example',
},
});
ok('管理端改 mail_mode / mail_from / public_url → 200', setMail.status === 200,
`status=${setMail.status} ${JSON.stringify(setMail.body)}`);
await sendReset();
const rows = readOutbox(D2.outbox).filter((x) => x.to === adminEmail);
ok('改成 outbox 之后**立刻**有信落盘(不重启、不等轮询)', rows.length === 1,
`${rows.length} 行`);
const mail = rows[rows.length - 1] || {};
ok(' outbox 记录带发件人(mail_from 被消费了)', mail.from === 'noreply@ink.example',
String(mail.from));
ok(' 正文落款含实例名与对外地址(public_url / instance_name 被消费了)',
String(mail.text || '').includes('改过的实例名')
&& String(mail.text || '').includes('https://ink.example'),
JSON.stringify(String(mail.text || '').slice(-80)));
const setSmtp = await req(D2.base, 'POST', '/api/admin/instance-config',
{ token: adminSession, body: { mail_mode: 'smtp' } });
ok('切到 smtp → 200(允许设置,但下面要证明它不会假装发出去了)',
setSmtp.status === 200, `status=${setSmtp.status}`);
const before = readOutbox(D2.outbox).length;
const smtpRes = await sendReset();
ok(' smtp 未接入时请求仍返回 200(不泄露"这封发没发出去")',
smtpRes.status === 200, `status=${smtpRes.status}`);
ok(' 但 outbox 没有新增行(没有偷偷降级成 outbox)',
readOutbox(D2.outbox).length === before,
`${before} → ${readOutbox(D2.outbox).length}`);
// 恢复 outbox,后续段(若有)不受影响
await req(D2.base, 'POST', '/api/admin/instance-config',
{ token: adminSession, body: { mail_mode: 'outbox' } });
}
// ── 【6f】审计保留期:填了才删,删了要留痕 ──
//
// 直接调真实模块(而不是等 6 小时的定时器),但库是真库、SQL 是真 SQL。
console.log('\n【6f】audit_retention_days:留空不删、填了就删、删完留痕');
{
const { createAuditPruner } = require(path.join(ROOT, 'server', 'shared', 'audit-prune.js'));
const pool = new pg.Pool({ connectionString: dbUrl(DB_FRESH) });
try {
const put = (event, ageSql) => q(DB_FRESH,
`INSERT INTO audit_log (event, created_at) VALUES ($1, now() - ${ageSql})`, [event]);
// ① 留空 = 永久:什么都不删
await put('probe_old_when_null', `interval '999 days'`);
let n = await q(DB_FRESH,
`SELECT count(*)::int AS n FROM audit_log WHERE event = 'probe_old_when_null'`);
ok('先造一条 999 天前的记录(前置条件)', n[0].n === 1, String(n[0].n));
const skip = await createAuditPruner(pool, {
getRetentionDays: async () => null, logger: { log() {}, warn() {} },
}).runOnce();
ok('保留期留空 → 跳过(skipped)', skip.skipped === true && skip.deleted === 0,
JSON.stringify(skip));
n = await q(DB_FRESH,
`SELECT count(*)::int AS n FROM audit_log WHERE event = 'probe_old_when_null'`);
ok(' 一行都没删(清理是破坏性的,默认必须是"不动")', n[0].n === 1, String(n[0].n));
// ② 填 30 天:999 天前的删掉,1 天前的不动
await put('probe_recent', `interval '1 day'`);
const run = await createAuditPruner(pool, {
getRetentionDays: async () => 30, logger: { log() {}, warn() {} },
}).runOnce();
ok('保留期 30 天 → 执行并删了 ≥1 行', run.skipped === false && run.deleted >= 1,
JSON.stringify(run));
const gone = await q(DB_FRESH,
`SELECT count(*)::int AS n FROM audit_log WHERE event = 'probe_old_when_null'`);
const kept = await q(DB_FRESH,
`SELECT count(*)::int AS n FROM audit_log WHERE event = 'probe_recent'`);
ok(' 999 天前的记录被删', gone[0].n === 0, String(gone[0].n));
ok(' 1 天前的记录还在(保留期不是"清空")', kept[0].n === 1, String(kept[0].n));
const trace = await q(DB_FRESH,
`SELECT detail FROM audit_log WHERE event = 'audit_pruned' ORDER BY id DESC LIMIT 1`);
ok(' 删了多少行有留痕(audit_pruned)', trace.length === 1
&& Number(trace[0].detail?.deleted) >= 1, JSON.stringify(trace[0]?.detail));
ok(' 留痕里写明按多少天删的', Number(trace[0]?.detail?.days) === 30,
JSON.stringify(trace[0]?.detail));
// ③ 非法值(0)不能被当成"删光"
await put('probe_zero_days', `interval '999 days'`);
const zero = await createAuditPruner(pool, {
getRetentionDays: async () => 0, logger: { log() {}, warn() {} },
}).runOnce();
ok('保留期写成 0(非法)→ 当作永久保留,不删', zero.skipped === true,
JSON.stringify(zero));
} finally {
await pool.end();
}
}
await D2.stop();
}
// ── 【7】并发:两个 complete 真正同时在临界区里 ──
//
// ⚠️ 这一段**不能只发两个 Promise.all 就完事**。首版就是那么写的,
// 结果两次请求里有一个在闸门(读状态发现已 ready)就被 404 挡了,
// 根本没走到"占位"那条 SQL —— 于是把 `AND bootstrap_state <> 'ready'`
// 删掉(真正的防线)测试依然全绿。**测不到防线的测试比没有更糟**。
//
// 现在用一把行锁把两个请求都压进临界区:测试自己开一个事务,
// `SELECT ... FOR UPDATE` 锁住 instance_config 的那一行并**不提交**;
// 两个 complete 都能过闸门(普通 SELECT 不被行锁挡),然后双双卡在
// UPDATE 上。等确认两边都卡住了再提交,让数据库去裁决谁先谁后。
// 这样"输家拿到 409"是**必然**,而不是碰运气。
console.log('\n【7】两个 complete 同时进入临界区 → 由数据库裁决,只有一个成功');
const E = await startBiz(DB_RACE, { INSTANCE_SETUP_TOKEN: TOKEN });
{
const mk = (n) => req(E.base, 'POST', '/api/setup/complete', {
token: TOKEN,
body: { admin: { email: `race-${n}-${tag}@example.com`, secret: adminSecret } },
});
const locker = new pg.Client({ connectionString: dbUrl(DB_RACE) });
await locker.connect();
await locker.query('BEGIN');
await locker.query('SELECT 1 FROM instance_config WHERE id = 1 FOR UPDATE');
const pa = mk('a');
const pb = mk('b');
// 给两个请求足够时间都撞到那把锁上(它们会在 UPDATE 处阻塞)
await new Promise((r) => setTimeout(r, 600));
await locker.query('COMMIT');
await locker.end();
const [a, b] = await Promise.all([pa, pb]);
const codes = [a.status, b.status].sort((x, y) => x - y);
ok('恰好一个 200', codes.filter((c) => c === 200).length === 1, JSON.stringify(codes));
const loser = a.status === 200 ? b : a;
// 两边都进过临界区,所以输家必须是 409(占位影响行数 0),
// 而不是 404(那说明它连闸门都没过 —— 等于这条护栏没被验证)
ok('另一个是 409 E_ALREADY_INITIALIZED(占位那句话真的在挡)',
loser.status === 409 && loser.body.error === 'E_ALREADY_INITIALIZED',
`status=${loser.status} error=${loser.body.error}`);
const st = await q(DB_RACE,
`SELECT (SELECT bootstrap_state FROM instance_config) AS st,
(SELECT count(*)::int FROM users WHERE is_admin) AS admins,
(SELECT count(*)::int FROM audit_log WHERE event = 'instance_initialized') AS audits`);
ok('库里状态 ready', st[0].st === 'ready', st[0].st);
ok('只有 1 个管理员(输家没有偷偷建号)', st[0].admins === 1, `${st[0].admins} 个`);
ok('审计只写 1 条(不能出现"两次初始化")', st[0].audits === 1, `${st[0].audits} 条`);
}
await E.stop();
// ── 【8】存量库升级:adopted,且不动已存在账号的口令 ──
console.log('\n【8】存量库升级(adopted)→ 不断服;已有邮箱只提权、不改口令');
const legacyEmail = `legacy-${tag}@example.com`;
const legacySecret = 'legacy-user-pw';
const formSecret = 'test-form-pw'; // 向导表单里填的新口令
await upgradeLegacy(DB_LEGACY, legacyEmail, legacySecret);
{
const r = await q(DB_LEGACY,
`SELECT bootstrap_state, (SELECT count(*)::int FROM users WHERE is_admin) AS admins
FROM instance_config`);
ok('迁移把有数据的旧库判成 adopted(而不是 pending)',
r[0].bootstrap_state === 'adopted', r[0].bootstrap_state);
ok('迁移不会凭空造管理员', r[0].admins === 0, `${r[0].admins} 个`);
}
const F = await startBiz(DB_LEGACY, { INSTANCE_SETUP_TOKEN: TOKEN });
{
const st = await req(F.base, 'GET', '/api/setup/state');
ok('/api/setup/state 报 adopted', st.body.state === 'adopted', String(st.body.state));
// 这是 adopted 与 pending 的关键差别:升级一台跑着几十台设备的实例,
// 先 503 五分钟等运维点向导,就是事故。
const me = await req(F.base, 'GET', '/api/accounts/me');
ok('业务端点**没有**被 503 挡住(升级不能断服)', me.status === 401, `status=${me.status}`);
const login0 = await req(F.base, 'POST', '/api/accounts/login',
{ body: { email: legacyEmail, secret: legacySecret } });
ok('存量用户照常登录(升级不改任何人的口令)',
login0.status === 200, `status=${login0.status}`);
const before = await q(DB_LEGACY,
'SELECT secret_hash, secret_salt FROM users WHERE email = $1', [legacyEmail]);
const r = await req(F.base, 'POST', '/api/setup/complete', {
token: TOKEN,
body: { admin: { email: legacyEmail, secret: formSecret } },
});
ok('complete → 200', r.status === 200, `status=${r.status}`);
ok(' promoted=true(这个邮箱本来就在)', r.body.admin?.promoted === true,
JSON.stringify(r.body.admin));
const after = await q(DB_LEGACY,
'SELECT is_admin, secret_hash, secret_salt FROM users WHERE email = $1', [legacyEmail]);
ok('已存在的账号被提升为管理员', after[0].is_admin === true, String(after[0].is_admin));
// 这条是整段的核心:自动改口令会造成"填错成别人邮箱 = 改了别人的口令"
ok('**口令哈希未被改写**(只提权,不动凭据)',
after[0].secret_hash === before[0].secret_hash
&& after[0].secret_salt === before[0].secret_salt);
ok('库里状态 ready', (await q(DB_LEGACY, 'SELECT bootstrap_state FROM instance_config'))
[0].bootstrap_state === 'ready');
const old = await req(F.base, 'POST', '/api/accounts/login',
{ body: { email: legacyEmail, secret: legacySecret } });
ok('用**原口令**仍能登录', old.status === 200, `status=${old.status}`);
const neu = await req(F.base, 'POST', '/api/accounts/login',
{ body: { email: legacyEmail, secret: formSecret } });
ok('用表单里那个口令登不进去(它没被写进库)', neu.status === 401, `status=${neu.status}`);
}
await F.stop();
} finally {
await stopAll();
for (const d of dbs) await dropDb(d);
}
// ────────────────────────── 汇总 ──────────────────────────
console.log(`\n${'─'.repeat(60)}`);
if (fail === 0) {
console.log(`✅ 安装向导:${pass} 项全部通过`);
} else {
console.log(`❌ 安装向导:${pass} 通过 / ${fail} 失败`);
for (const f of failures) console.log(' · ' + f);
}
process.exit(fail === 0 ? 0 : 1);
}
main().catch(async (e) => {
console.error('\n[FATAL] 测试自身出错:', e && e.stack || e);
await stopAll();
process.exit(1);
});