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