import { describe, expect, it } from "vitest"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import Database from "better-sqlite3"; const root = path.resolve(process.cwd()); const cli = path.join(root, "server", "cli", "admin-init.ts"); const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs"); const ptyHelper = path.join(root, "tests", "helpers", "pty-run.py"); const hasPython3 = spawnSync("python3", ["--version"]).status === 0; const ttyTest = hasPython3 ? it : it.skip; function runAdmin(dataDir: string, args: string[]) { return spawnSync(process.execPath, [tsx, cli, ...args], { cwd: root, env: { ...process.env, NODE_ENV: "test", TALLYNOTE_DATA_DIR: dataDir, TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999", TALLYNOTE_COOKIE_SECURE: "false", TALLYNOTE_UPDATE_STRATEGY: "disabled", }, encoding: "utf8", }); } function testEnv(dataDir: string) { return { ...process.env, NODE_ENV: "test", TALLYNOTE_DATA_DIR: dataDir, TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999", TALLYNOTE_COOKIE_SECURE: "false", TALLYNOTE_UPDATE_STRATEGY: "disabled", }; } // The CI runner has no `expect` binary. Drive the interactive CLI through a // real pseudo-terminal via a tiny Python pty helper (python3 ships on both // macOS and the Linux CI image). This avoids `expect` (not installed on CI) // and BSD `script` (injects a stray EOT byte from file input, corrupting the // first prompt value). If python3 is unavailable the tests are skipped rather // than failing the build. function runAdminTTY(dataDir: string, args: string[], inputText: string) { const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-tty-")); const inputFile = path.join(parent, "input"); const exitFile = path.join(parent, "exit-code"); writeFileSync(inputFile, inputText); try { const result = spawnSync("python3", [ptyHelper, process.execPath, tsx, cli, ...args], { cwd: root, env: { ...testEnv(dataDir), PTY_STDIN_FILE: inputFile, PTY_EXIT_FILE: exitFile }, encoding: "utf8", timeout: 30_000, }); const exitCode = existsSync(exitFile) ? Number(readFileSync(exitFile, "utf8")) : null; return { exitCode, output: `${result.stdout}${result.stderr}`, spawnError: result.error }; } finally { rmSync(parent, { recursive: true, force: true }); } } describe("生产管理员初始化 CLI", () => { it("--check 是只读的,空数据目录不会被创建", () => { const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-")); const dataDir = path.join(parent, "data"); try { const result = runAdmin(dataDir, ["--check"]); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("empty"); expect(existsSync(dataDir)).toBe(false); expect(existsSync(path.join(dataDir, "tallynote.db"))).toBe(false); } finally { rmSync(parent, { recursive: true, force: true }); } }); it("--check 不会执行迁移或创建 schema_migrations", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-")); const database = new Database(path.join(dataDir, "tallynote.db")); database.exec("CREATE TABLE admins (id TEXT PRIMARY KEY)"); database.close(); try { const result = runAdmin(dataDir, ["--check"]); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("empty"); const verify = new Database(path.join(dataDir, "tallynote.db"), { readonly: true }); const schemaMigrations = verify .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'") .get(); expect(schemaMigrations).toBeUndefined(); verify.close(); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); it("只允许初始化首位管理员,并写入一次性密码和审计记录", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); try { const first = runAdmin(dataDir, ["--username", "admin", "--display-name", "管理员", "--generate"]); expect(first.status).toBe(0); expect(first.stdout).toMatch(/已创建首位管理员。一次性密码:\S+/); const database = new Database(path.join(dataDir, "tallynote.db")); const admin = database.prepare("SELECT username, display_name, must_change_password FROM admins").get() as { username: string; display_name: string; must_change_password: number }; const audit = database.prepare("SELECT action, actor_username FROM audit_events ORDER BY occurred_at DESC LIMIT 1").get() as { action: string; actor_username: string }; expect(admin).toEqual({ username: "admin", display_name: "管理员", must_change_password: 1 }); expect(audit).toEqual({ action: "admin.initialized", actor_username: "cli" }); database.close(); const check = runAdmin(dataDir, ["--check"]); expect(check.status).toBe(0); expect(check.stdout.trim()).toBe("initialized"); const second = runAdmin(dataDir, ["--username", "other", "--display-name", "其他", "--generate"]); expect(second.status).not.toBe(0); expect(`${second.stdout}${second.stderr}`).toContain("INITIAL_ADMIN_EXISTS"); } finally { rmSync(dataDir, { recursive: true, force: true }); } }, 15_000); ttyTest("交互式输入正式密码后不会强制首次改密", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); try { const result = runAdminTTY(dataDir, [], "manual-admin\n手动管理员\nStrong-password-2026!\nStrong-password-2026!\n"); expect(result.spawnError).toBeUndefined(); expect(result.exitCode).toBe(0); expect(result.output).toContain("已创建首位管理员"); expect(result.output).toContain("Strong-password-2026!"); const database = new Database(path.join(dataDir, "tallynote.db")); const admin = database.prepare("SELECT username, must_change_password FROM admins").get() as { username: string; must_change_password: number }; expect(admin).toEqual({ username: "manual-admin", must_change_password: 0 }); database.close(); } finally { rmSync(dataDir, { recursive: true, force: true }); } }, 30_000); ttyTest("可以验证当前密码并清除旧版本遗留的首次改密标志", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); try { const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]); expect(first.status).toBe(0); const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1]; expect(generated).toBeTruthy(); const result = runAdminTTY(dataDir, ["--mark-password-configured", "--username", "legacy-admin"], `${generated}\n`); expect(result.spawnError).toBeUndefined(); expect(result.exitCode).toBe(0); expect(result.output).toContain("已确认当前密码为正式密码"); const database = new Database(path.join(dataDir, "tallynote.db")); const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number }; expect(admin.must_change_password).toBe(0); database.close(); } finally { rmSync(dataDir, { recursive: true, force: true }); } }, 30_000); it("密码输入不是 TTY 时明确拒绝通过管道传入", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); try { const result = spawnSync(process.execPath, [tsx, cli], { cwd: root, input: "admin\n管理员\npassword-password\npassword-password\n", env: { ...process.env, NODE_ENV: "test", TALLYNOTE_DATA_DIR: dataDir, TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999", TALLYNOTE_COOKIE_SECURE: "false", TALLYNOTE_UPDATE_STRATEGY: "disabled", }, encoding: "utf8", }); expect(result.status).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toContain("交互式 TTY"); expect(readFileSync(path.join(dataDir, "tallynote.db"))).toBeTruthy(); } finally { rmSync(dataDir, { recursive: true, force: true }); } }); });