import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { sql } from "@umt/db"; import { createMockEmby, createMockQb, type MockEmby, type MockQb } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { DEV_KEY, json } from "../helpers.ts"; import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts"; const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => { let h: Harness; let admin: ReturnType; let qb: MockQb; let emby: MockEmby; const dir = mkdtempSync(join(tmpdir(), "umt-w2a-")); beforeAll(async () => { h = await createHarness({ engine: true }); admin = bearer(h.app, DEV_KEY); qb = await createMockQb({ username: "qbuser", password: "qb-secret-pass" }); emby = await createMockEmby({ apiKey: "emby-secret-key" }); }, 60_000); afterAll(async () => { await qb?.close(); await emby?.close(); await h?.close(); rmSync(dir, { recursive: true, force: true }); }); const noLeak = (secret: string) => { expect(h.lines.join("\n")).not.toContain(secret); }; describe("API Key", () => { it("创建一次性返回明文;列表不含明文/哈希;scope 校验;read key 不能写;撤销后 401", async () => { expect((await admin("POST", "/api-keys", { name: "bad", scopes: ["root"] })).status).toBe(422); expect((await admin("POST", "/api-keys", { name: "empty", scopes: [] })).status).toBe(422); const created = await json(await admin("POST", "/api-keys", { name: "reader", scopes: ["read", "read"] })); const key: string = created.data.plaintextKey; expect(key).toMatch(/^umt_/); expect(created.data.apiKey.scopes).toEqual(["read"]); const list = await json(await admin("GET", "/api-keys")); const text = JSON.stringify(list); expect(text).not.toContain(key); expect(text).not.toContain("keyHash"); expect(list.data.items[0].name).toBe("reader"); const reader = bearer(h.app, key); expect((await reader("GET", "/settings")).status).toBe(200); expect((await reader("PATCH", "/settings", { automation: { enabled: false } })).status).toBe(403); expect((await reader("GET", "/api-keys")).status).toBe(403); expect((await reader("GET", "/engine/runs")).status).toBe(403); const id = created.data.apiKey.id; expect((await admin("PATCH", `/api-keys/${id}`, { scopes: ["read", "organize.read"] })).status).toBe(200); expect((await admin("DELETE", `/api-keys/${id}`)).status).toBe(200); expect((await reader("GET", "/settings")).status).toBe(401); expect((await admin("DELETE", "/api-keys/00000000-0000-4000-8000-000000000000")).status).toBe(404); noLeak(key); }); it("游标分页", async () => { for (const n of ["k1", "k2", "k3"]) await admin("POST", "/api-keys", { name: n, scopes: ["read"] }); const p1 = await json(await admin("GET", "/api-keys?limit=2")); expect(p1.data.items).toHaveLength(2); const p2 = await json(await admin("GET", `/api-keys?limit=2&cursor=${p1.data.nextCursor}`)); const ids = [...p1.data.items, ...p2.data.items].map((x: { id: string }) => x.id); expect(new Set(ids).size).toBe(ids.length); expect((await admin("GET", "/api-keys?cursor=garbage")).status).toBe(400); }); }); describe("设置", () => { it("GET 缺省值;PATCH 自动化总开关与只读对账;ctx.settings 缓存失效并广播 settings.changed", async () => { const g = await json(await admin("GET", "/settings")); expect(g.data.automation).toEqual({ enabled: true, readOnlyReconcile: false, workerLanes: { file: 2, network: 4, maintenance: 1 }, }); expect((await h.ctx.settings.get("automation")).enabled).toBe(true); const events: string[] = []; const off = h.bus.subscribe((e) => events.push(e.type)); const r = await admin("PATCH", "/settings", { automation: { enabled: false, readOnlyReconcile: true } }); expect(r.status).toBe(200); off(); expect(events).toContain("settings.changed"); expect(await h.ctx.settings.get("automation")).toMatchObject({ enabled: false, readOnlyReconcile: true }); // 其它分组不变 expect((await json(await admin("GET", "/settings"))).data.cache.hardThresholdPercent).toBe(90); }); it("非法合并结果 → 422(恢复水位必须低于硬阈值)", async () => { const r = await admin("PATCH", "/settings", { cache: { resumePercent: 95 } }); expect(r.status).toBe(422); expect((await json(r)).error.message).toMatch(/恢复水位/); }); it("旧 pi.apiKey / provider / model 不再接受:被丢弃,不建 secret,不改设置", async () => { const plain = "sk-live-super-secret-0001"; const before = (await json(await admin("GET", "/settings"))).data.pi; const r = await json( await admin("PATCH", "/settings", { pi: { apiKey: plain, provider: "openai", model: "claude-x" } }), ); expect(r.data.pi).toMatchObject({ provider: before.provider, model: before.model, apiKeySecretRef: before.apiKeySecretRef, }); expect(JSON.stringify(r)).not.toContain(plain); const n = await sql<{ n: number }>`select count(*)::int as n from secret where purpose = 'llm_api_key'`.execute( h.db, ); expect(n.rows[0]!.n).toBe(0); noLeak(plain); }); }); describe("凭证", () => { it("/secrets 只写明文,列表只有元数据;重名 409;轮换;被引用时删除 409", async () => { const c = await json( await admin("POST", "/secrets", { name: "tmdb", purpose: "metadata_api_key", plaintext: "tmdb-plain-xyz" }), ); expect(c.data).toMatchObject({ name: "tmdb", purpose: "metadata_api_key", algorithm: "aes-256-gcm", kdf: "hkdf-sha256", }); expect(JSON.stringify(c)).not.toContain("tmdb-plain-xyz"); expect((await admin("POST", "/secrets", { name: "tmdb", purpose: "other", plaintext: "y" })).status).toBe(409); const list = await json(await admin("GET", "/secrets")); expect(JSON.stringify(list)).not.toMatch(/ciphertext|nonce|authTag|tmdb-plain-xyz/); const rot = await json(await admin("PATCH", `/secrets/${c.data.id}`, { plaintext: "tmdb-plain-2" })); expect(rot.data.rotatedAt).toBeTruthy(); expect(await h.ctx.secrets.resolve(c.data.id)).toBe("tmdb-plain-2"); expect((await admin("DELETE", `/secrets/${c.data.id}`)).status).toBe(200); noLeak("tmdb-plain"); }); }); describe("实例与路径", () => { it("library_root:登记写哨兵文件;路径不存在 412;test 反映哨兵在线/离线;重名 409", async () => { const r = await admin("POST", "/library-roots", { name: "电影", localPath: dir, mediaKind: "movie" }); expect(r.status).toBe(200); const root = (await json(r)).data; expect(root).toMatchObject({ sentinelFile: ".uhd-root", healthStatus: "healthy", managementMode: "register_only", }); expect(existsSync(join(dir, ".uhd-root"))).toBe(true); expect((await admin("POST", "/library-roots", { name: "电影", localPath: `${dir}/x` })).status).toBe(412); expect((await admin("POST", "/library-roots", { name: "电影", localPath: dir })).status).toBe(409); expect((await json(await admin("POST", `/library-roots/${root.id}/test`))).data.ok).toBe(true); rmSync(join(dir, ".uhd-root")); const t = (await json(await admin("POST", `/library-roots/${root.id}/test`))).data; expect(t).toMatchObject({ ok: false, healthStatus: "offline" }); expect((await json(await admin("GET", `/library-roots/${root.id}`))).data.healthStatus).toBe("offline"); expect((await admin("DELETE", `/library-roots/${root.id}`)).status).toBe(200); expect((await admin("GET", `/library-roots/${root.id}`)).status).toBe(404); }); it("downloader_instance:凭证只写 → secretRef;test 用 qB client 测连通;错密码 → ok=false;删除连带删 secret", async () => { const c = await json( await admin("POST", "/downloader-instances", { name: "qb1", baseUrl: qb.url, username: "qbuser", credential: "qb-secret-pass", }), ); const inst = c.data; expect(inst.secretRef).toBeTruthy(); expect(JSON.stringify(c)).not.toContain("qb-secret-pass"); const t = (await json(await admin("POST", `/downloader-instances/${inst.id}/test`))).data; expect(t).toMatchObject({ ok: true, version: expect.any(String), healthStatus: "healthy" }); const got = (await json(await admin("GET", `/downloader-instances/${inst.id}`))).data; expect(got.healthStatus).toBe("healthy"); expect(got.versionInfo.app).toBeTruthy(); await admin("PATCH", `/downloader-instances/${inst.id}`, { credential: "wrong-pass" }); const again = (await json(await admin("GET", `/downloader-instances/${inst.id}`))).data; expect(again.secretRef).toBe(inst.secretRef); const bad = (await json(await admin("POST", `/downloader-instances/${inst.id}/test`))).data; expect(bad).toMatchObject({ ok: false, healthStatus: "offline" }); expect((await admin("DELETE", `/downloader-instances/${inst.id}`)).status).toBe(200); await expect(h.ctx.secrets.resolve(inst.secretRef)).rejects.toThrow(/不存在/); noLeak("qb-secret-pass"); }); it("downloader_instance 认证方式三选一:API Key(不登录)/ 无认证(内网白名单)/ 账密;切换校验与凭证清理", async () => { const k52 = await createMockQb({ version: "5.2.4", apiKey: "qbt_live_key" }); const open = await createMockQb({ version: "5.2.4", bypassAuth: true }); try { expect( (await admin("POST", "/downloader-instances", { name: "k0", baseUrl: k52.url, authKind: "api_key" })).status, ).toBe(422); expect( (await admin("POST", "/downloader-instances", { name: "p0", baseUrl: k52.url, authKind: "password" })).status, ).toBe(422); const k = ( await json( await admin("POST", "/downloader-instances", { name: "k52", baseUrl: k52.url, authKind: "api_key", credential: "qbt_live_key", }), ) ).data; expect(k.settings.authKind).toBe("api_key"); expect(JSON.stringify(k)).not.toContain("qbt_live_key"); expect((await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data.ok).toBe(true); expect(k52.callsTo("auth/login")).toHaveLength(0); await admin("PATCH", `/downloader-instances/${k.id}`, { credential: "qbt_wrong" }); const bad = (await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data; expect(bad.ok).toBe(false); expect(bad.message).toContain("API Key"); const n = ( await json( await admin("POST", "/downloader-instances", { name: "open", baseUrl: open.url, authKind: "none" }), ) ).data; expect(n.secretRef).toBeNull(); expect((await json(await admin("POST", `/downloader-instances/${n.id}/test`))).data.ok).toBe(true); expect(open.callsTo("auth/login")).toHaveLength(0); // 切到无认证:凭证删除;无认证连需要认证的实例 → ok=false const sw = (await json(await admin("PATCH", `/downloader-instances/${k.id}`, { authKind: "none" }))).data; expect(sw.secretRef).toBeNull(); await expect(h.ctx.secrets.resolve(k.secretRef)).rejects.toThrow(/不存在/); expect((await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data.ok).toBe(false); // 再切回 API Key 必须带 Key expect((await admin("PATCH", `/downloader-instances/${k.id}`, { authKind: "api_key" })).status).toBe(422); const back = ( await json( await admin("PATCH", `/downloader-instances/${k.id}`, { authKind: "api_key", credential: "qbt_live_key" }), ) ).data; expect(back.secretRef).toBeTruthy(); expect((await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data.ok).toBe(true); for (const id of [k.id, n.id]) await admin("DELETE", `/downloader-instances/${id}`); noLeak("qbt_live_key"); } finally { await k52.close(); await open.close(); } }); it("media_server_instance:无 key 时 test 报未配置;有 key 时 ping 成功", async () => { const a = (await json(await admin("POST", "/media-server-instances", { name: "emby-nokey", baseUrl: emby.url }))) .data; expect((await json(await admin("POST", `/media-server-instances/${a.id}/test`))).data).toMatchObject({ ok: false, }); const b = ( await json( await admin("POST", "/media-server-instances", { name: "emby", baseUrl: emby.url, credential: "emby-secret-key", }), ) ).data; const t = (await json(await admin("POST", `/media-server-instances/${b.id}/test`))).data; expect(t).toMatchObject({ ok: true, healthStatus: "healthy" }); expect((await json(await admin("GET", "/media-server-instances"))).data.items).toHaveLength(2); }); it("path_mapping:viewKind 与实例不匹配 422;verify 按本地目录可达判定", async () => { const d = (await json(await admin("POST", "/downloader-instances", { name: "qb2", baseUrl: qb.url }))).data; expect( ( await admin("POST", "/path-mappings", { viewKind: "media_server", downloaderInstanceId: d.id, remotePrefix: "/downloads", localPrefix: dir, }) ).status, ).toBe(422); const m = ( await json( await admin("POST", "/path-mappings", { viewKind: "downloader", downloaderInstanceId: d.id, remotePrefix: "/downloads", localPrefix: dir, }), ) ).data; expect(m.verifyStatus).toBe("unverified"); expect((await json(await admin("POST", `/path-mappings/${m.id}/verify`))).data.verifyStatus).toBe("ok"); await admin("PATCH", `/path-mappings/${m.id}`, { localPrefix: `${dir}/missing` }); const v = (await json(await admin("POST", `/path-mappings/${m.id}/verify`))).data; expect(v).toMatchObject({ verifyStatus: "failed" }); expect(v.verifyDetail.code).toBe("ENOENT"); expect((await admin("DELETE", `/path-mappings/${m.id}`)).status).toBe(200); }); }); describe("审计查询", () => { it("按 action 前缀 / target / actor 过滤,游标翻页", async () => { const all = await json(await admin("GET", "/audit-events?action=api_key.*&limit=200")); expect(all.data.items.length).toBeGreaterThanOrEqual(4); expect(all.data.items.every((e: { action: string }) => e.action.startsWith("api_key."))).toBe(true); const byActor = await json(await admin("GET", "/audit-events?actorType=user&actorId=dev-admin&limit=200")); expect(byActor.data.items.length).toBeGreaterThan(0); const p1 = await json(await admin("GET", "/audit-events?limit=3")); const p2 = await json(await admin("GET", `/audit-events?limit=3&cursor=${p1.data.nextCursor}`)); const ids = [...p1.data.items, ...p2.data.items].map((e: { id: string }) => e.id); expect(new Set(ids).size).toBe(6); expect(p1.data.items[0].at >= p2.data.items[0].at).toBe(true); const t = await json(await admin("GET", "/audit-events?targetType=api_key&limit=200")); expect(t.data.items.length).toBeGreaterThan(0); expect(t.data.items.every((e: { action: string }) => e.action.startsWith("api_key."))).toBe(true); }); }); describe("引擎面板", () => { const ow = () => h.engineSchema; beforeAll(async () => { const t = (n: string) => sql.raw(`"${ow()}".${n}`); const run = (id: string, status: string, name: string, at: string, extra = "") => sql`insert into ${t("workflow_runs")} (namespace_id, id, workflow_name, version, status, config, context, input, attempts, worker_id, available_at, started_at, created_at, updated_at) values ('default', ${id}, ${name}, 'v1', ${status}, '{"retry":1}'::jsonb, '{"secret":"ctx"}'::jsonb, ${JSON.stringify({ url: "https://x/rss?passkey=abc123", password: "p@ss", n: 1, extra })}::jsonb, 1, ${status === "running" ? "worker-a" : null}, ${new Date(at)}, ${new Date(at)}, ${new Date(at)}, ${new Date(at)})`.execute( h.db, ); await run("r1", "completed", "ingestFeed", "2026-09-01T00:00:00Z"); await run("r2", "failed", "ingestFeed", "2026-09-02T00:00:00Z"); await run("r3", "running", "submitDownload", "2026-09-03T00:00:00Z"); await run("r4", "pending", "submitDownload", "2026-09-04T00:00:00Z"); await sql`insert into ${t("step_attempts")} (namespace_id, id, workflow_run_id, step_name, kind, status, config, context, output, started_at, created_at, updated_at) values ('default', 's1', 'r3', 'wait-done', 'signal-wait', 'running', '{}'::jsonb, '{"kind":"signal-wait","signal":"umt:r3:done","timeoutAt":"2026-09-05T00:00:00.000Z"}'::jsonb, null, now(), now(), now()), ('default', 's0', 'r3', 'login', 'function', 'completed', '{}'::jsonb, null, '{"token":"tkn-1","ok":true}'::jsonb, now(), now() - interval '1 minute', now())`.execute( h.db, ); await sql`insert into ${t("workflow_signals")} (namespace_id, id, signal, data, workflow_run_id, step_attempt_id) values ('default', 'sig1', 'umt:r3:done', '{"cookie":"c=1"}'::jsonb, 'r3', 's1')`.execute(h.db); }); it("runs 列表:状态/名称/时间筛选 + 游标", async () => { const all = await json(await admin("GET", "/engine/runs")); expect(all.data.items.map((r: { id: string }) => r.id)).toEqual(["r4", "r3", "r2", "r1"]); expect(JSON.stringify(all)).not.toMatch(/config|context|passkey/); const byName = await json(await admin("GET", "/engine/runs?workflowName=ingestFeed&status=failed")); expect(byName.data.items.map((r: { id: string }) => r.id)).toEqual(["r2"]); const ranged = await json(await admin("GET", "/engine/runs?from=2026-09-02T00:00:00Z&to=2026-09-04T00:00:00Z")); expect(ranged.data.items.map((r: { id: string }) => r.id)).toEqual(["r3", "r2"]); const p1 = await json(await admin("GET", "/engine/runs?limit=3")); const p2 = await json(await admin("GET", `/engine/runs?limit=3&cursor=${p1.data.nextCursor}`)); expect(p2.data.items.map((r: { id: string }) => r.id)).toEqual(["r1"]); expect(p2.data.nextCursor).toBeNull(); expect((await admin("GET", "/engine/runs?status=bogus")).status).toBe(422); const multi = await json(await admin("GET", "/engine/runs?status=running,pending")); expect(multi.data.items.map((r: { id: string }) => r.id)).toEqual(["r4", "r3"]); expect((await admin("GET", "/engine/runs?status=running,bogus")).status).toBe(422); }); it("run 详情:step attempts + signals;敏感字段脱敏,不返回 config/context", async () => { const d = (await json(await admin("GET", "/engine/runs/r3"))).data; expect(d.input).toMatchObject({ password: "[REDACTED]", n: 1 }); expect(d.input.url).not.toContain("abc123"); expect(d.stepAttempts.map((s: { id: string }) => s.id)).toEqual(["s0", "s1"]); expect(d.stepAttempts[0].output).toEqual({ token: "[REDACTED]", ok: true }); expect(d.stepAttempts[1]).toMatchObject({ waitSignal: "umt:r3:done", timeoutAt: "2026-09-05T00:00:00.000Z" }); expect(d.signals[0]).toMatchObject({ signal: "umt:r3:done", data: { cookie: "[REDACTED]" } }); expect(JSON.stringify(d)).not.toMatch(/"config"|"context"|"retry"|"ctx"/); expect((await admin("GET", "/engine/runs/nope")).status).toBe(404); }); it("stats:按状态计数、最老待处理、活跃 worker 快照", async () => { const s = (await json(await admin("GET", "/engine/stats"))).data; expect(s.byStatus).toMatchObject({ pending: 1, running: 1, completed: 1, failed: 1, canceled: 0 }); expect(s.oldestPending).toMatchObject({ id: "r4", workflowName: "submitDownload" }); expect(s.activeWorkers).toEqual([{ workerId: "worker-a", running: 1, since: "2026-09-03T00:00:00.000Z" }]); }); it("cancel 走 TaskEngine.cancel 并审计;未知 run 404", async () => { const r = await json(await admin("POST", "/engine/runs/r4/cancel", { reason: "测试" })); expect(r.data).toEqual({ canceled: true }); expect(h.cancel).toHaveBeenCalledWith("r4", "测试"); expect((await admin("POST", "/engine/runs/nope/cancel", {})).status).toBe(404); const a = await sql<{ n: number; }>`select count(*)::int as n from audit_event where action = 'engine.run_cancel'`.execute(h.db); expect(a.rows[0]!.n).toBe(1); }); }); });