import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { DB, Kysely } from "@umt/db"; import { afterAll, describe, expect, it } from "vitest"; import { type Config, ConfigSchema, DATA_SUBDIRS, dataPaths } from "../src/config.ts"; import { diffCounts } from "../src/ops.ts"; import { checkStorage, runPreflight } from "../src/preflight.ts"; const dir = mkdtempSync(join(tmpdir(), "umt-pre-")); afterAll(() => rmSync(dir, { recursive: true, force: true })); // 最小假 Kysely:只需要 sql`select 1`.execute(db) 走得通或失败 const fakeDb = (ok: boolean) => ({ getExecutor: () => ({ transformQuery: (n: unknown) => n, compileQuery: (n: unknown) => ({ sql: "select 1", parameters: [], query: n }), executeQuery: async () => { if (!ok) throw new Error("connect ECONNREFUSED"); return { rows: [{ "?column?": 1 }] }; }, }), }) as unknown as Kysely; const cfg = (over: Record): Config => ConfigSchema.parse({ DATABASE_URL: "postgres://x/y", SECRET_MASTER_KEY: "a-real-random-secret-value-123", ...over }); describe("启动自检", () => { it("开发环境、DB 可达 → 通过", async () => { expect(await runPreflight({ config: cfg({}), db: fakeDb(true), uid: 0 })).toEqual([]); }); it("生产环境:占位符密钥、root、DB 不可达、WEB_ROOT 缺 index.html 都报出", async () => { const problems = await runPreflight({ config: cfg({ NODE_ENV: "production", SECRET_MASTER_KEY: "dev-only-change-me-dev-only", WEB_ROOT: dir }), db: fakeDb(false), uid: 0, }); expect(problems.map((p) => p.check).sort()).toEqual(["db", "secret", "user", "web"]); }); it("WEB_ROOT 有 index.html、非 root → 通过", async () => { writeFileSync(join(dir, "index.html"), ""); const config = cfg({ NODE_ENV: "production", WEB_ROOT: dir }); expect(await runPreflight({ config, db: fakeDb(true), uid: 1000 })).toEqual([]); }); }); describe("存储自检", () => { it("DATA_DIR 缺省为 ./data,会话目录缺省在其下", () => { const p = dataPaths(cfg({}), "/srv/app"); expect(p.dataDir).toBe("/srv/app/data"); expect(p.sessionDir).toBe("/srv/app/data/agent-sessions"); expect(dataPaths(cfg({ DATA_DIR: "/data", AGENT_SESSION_DIR: "/x/s" })).sessionDir).toBe("/x/s"); }); it("建数据子目录并探测可写;挂载路径不存在/不可写只告警", async () => { const dataDir = join(dir, "data"); const ok = join(dir, "lib-ok"); const ro = join(dir, "lib-ro"); mkdirSync(ok); mkdirSync(ro); chmodSync(ro, 0o555); const isRoot = Deno.uid() === 0; const { problems, warnings } = await checkStorage({ dataDir, sessionDir: join(dataDir, "agent-sessions"), subdirs: DATA_SUBDIRS, mounts: [ { kind: "library_root", id: "1", name: "ok", path: ok }, { kind: "library_root", id: "2", name: "ro", path: ro }, { kind: "path_mapping", id: "3", name: "/downloads", path: join(dir, "missing") }, ], }); expect(problems).toEqual([]); expect(readdirSync(dataDir).sort()).toEqual([...DATA_SUBDIRS].sort()); // 探针文件已删除 expect(readdirSync(ok)).toEqual([]); expect(warnings.map((w) => w.id)).toEqual(isRoot ? ["3"] : ["2", "3"]); chmodSync(ro, 0o755); }); it("数据目录不可写 → problem", async () => { if (Deno.uid() === 0) return; // root 无视权限位 const locked = join(dir, "locked"); mkdirSync(locked); chmodSync(locked, 0o555); const { problems } = await checkStorage({ dataDir: join(locked, "data"), sessionDir: join(locked, "data", "agent-sessions"), subdirs: DATA_SUBDIRS, mounts: [], }); expect(problems.every((p) => p.check === "data_dir")).toBe(true); expect(problems.length).toBe(DATA_SUBDIRS.length); chmodSync(locked, 0o755); }); it("映射清单行数比对", () => { expect(diffCounts({ a: 1, b: 2 }, { a: 1, b: 2 })).toEqual([]); expect(diffCounts({ a: 1, b: 2 }, { a: 1, b: 3 })).toEqual([{ table: "b", expected: 2, actual: 3 }]); }); });