import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { sql } from "@umt/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createSecretStore, masterKeyId, rotateMasterKey, SecretError, } from "../../src/modules/auth-settings/provided.ts"; import { memoryLogger } from "../helpers.ts"; import { createHarness, type Harness, MASTER_KEY, PG_URL, pgAvailable } from "./harness.ts"; // 主密钥轮换(--role rotate-master-key):逐行事务重新加密、幂等重跑、解不开的行不动、新旧对调即回滚。 // 每个用例一个临时 schema(harness),不碰开发库的 public。 const hasPg = await pgAvailable(); const NEW_KEY = "rotated-master-key-abcdefghijkl"; describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => { let h: Harness; beforeAll(async () => { h = await createHarness(); }, 60_000); afterAll(async () => { await h?.close(); }); it("旧密钥 → 新密钥:全部换新、幂等、解不开的行原样保留;对调重跑即回滚", async () => { const oldStore = createSecretStore({ db: h.db, masterKey: MASTER_KEY }); const refs: string[] = []; for (let i = 0; i < 7; i++) refs.push(await oldStore.create({ purpose: "site_cookie", plaintext: `cookie-${i}`, label: `s${i}` })); // 一条用别的密钥写的"坏行"(新旧都解不开) const bad = await createSecretStore({ db: h.db, masterKey: "some-unknown-master-key-xx" }).create({ purpose: "other", plaintext: "lost", }); const before = await sql<{ id: string; ciphertext: Buffer; }>`select id, ciphertext from secret where id = ${bad}`.execute(h.db); const { logger, lines } = memoryLogger(); const r1 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY, { progressEvery: 3 }); expect(r1).toEqual({ total: 8, rotated: 7, skipped: 0, failed: [bad] }); // 进度日志:每 3 行一条 + 结尾;不含明文 expect(lines.filter((l) => l.includes("主密钥轮换进度")).length).toBeGreaterThanOrEqual(3); expect(lines.some((l) => l.includes("cookie-"))).toBe(false); const newStore = createSecretStore({ db: h.db, masterKey: NEW_KEY }); for (let i = 0; i < refs.length; i++) expect(await newStore.resolve(refs[i]!)).toBe(`cookie-${i}`); await expect(oldStore.resolve(refs[0]!)).rejects.toBeInstanceOf(SecretError); const kids = await sql<{ kid: string }>`select kdf_params->>'kid' as kid from secret where id <> ${bad}`.execute( h.db, ); expect(new Set(kids.rows.map((k) => k.kid))).toEqual(new Set([masterKeyId(NEW_KEY)])); const after = await sql<{ ciphertext: Buffer }>`select ciphertext from secret where id = ${bad}`.execute(h.db); expect(Buffer.compare(after.rows[0]!.ciphertext, before.rows[0]!.ciphertext)).toBe(0); // 中断后重跑:已换过的跳过 const r2 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY); expect(r2).toEqual({ total: 8, rotated: 0, skipped: 7, failed: [bad] }); const audit = await sql<{ payload: { rotated: number } }>` select payload from audit_event where action = 'secret.master_key_rotate' order by at, id`.execute(h.db); expect(audit.rows.map((a) => a.payload.rotated)).toEqual([7, 0]); // 回滚:新旧对调 const back = await rotateMasterKey({ db: h.db, logger }, NEW_KEY, MASTER_KEY); expect(back).toMatchObject({ rotated: 7, skipped: 0 }); expect(await oldStore.resolve(refs[3]!)).toBe("cookie-3"); await expect(rotateMasterKey({ db: h.db, logger }, NEW_KEY, NEW_KEY)).rejects.toThrow(/相同/); await sql`delete from secret where id = ${bad}`.execute(h.db); }); it("命令行 --role rotate-master-key:读 SECRET_MASTER_KEY_NEW;缺少时退出码 2", async () => { const store = createSecretStore({ db: h.db, masterKey: MASTER_KEY }); const ref = await store.create({ purpose: "downloader_password", plaintext: "qb-pass" }); const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url)); const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`; const run = (extra: Record) => spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], { env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", DATABASE_URL: url, SECRET_MASTER_KEY: MASTER_KEY, LOG_LEVEL: "info", DATA_DIR: "/tmp", ...extra, }, encoding: "utf8", timeout: 120_000, }); expect(run({}).status).toBe(2); const ok = run({ SECRET_MASTER_KEY_NEW: NEW_KEY }); expect(ok.status, ok.stderr + ok.stdout).toBe(0); expect(ok.stdout).toContain("主密钥轮换完成"); expect(await createSecretStore({ db: h.db, masterKey: NEW_KEY }).resolve(ref)).toBe("qb-pass"); }, 180_000); });