Files
UHD_Media_Tool/apps/server/test/auth-settings/admin-api.test.ts
T
rootandClaude Opus 5.5 0019dc1afe copy(server): 下载、整理、站点、浏览器、字幕等模块错误与提示文案重写
缓存卷 → 下载盘;站点快照 → 站点列表抓取;Feed → RSS;去掉猜测原因与「请…」;同步断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:52:24 +08:00

401 lines
21 KiB
TypeScript

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<typeof bearer>;
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);
});
});
});