Files
UHD_Media_Tool/apps/server/test/auth-settings/admin-api.test.ts
T
rootandClaude Opus 5.5 05a7706dde feat(sources): site center backend (credentials, refresh login, user data, health check, delete)
PUT /sites/{id}/credentials by kind (moved from auth-settings, legacy body
accepted), POST refresh-login (async task or wait), POST health-check,
GET user-data history, DELETE /sites/{id} (soft delete, releases kept),
DELETE /site-accounts/{id}, GET /sites?include=accounts; refreshSiteAccount
task with login-failure decisions and auto re-login on auth_expired;
scheduled jobs sites.user_data (6h) and sites.health_check (1h);
settings.sites defaults for rate limit, snapshot interval, UA and proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 15:18:40 +08:00

334 lines
17 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 });
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")).toEqual({ 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("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);
});
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);
});
});
});