feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07db0dac5b
commit
ff230e5da7
34 files changed
+4363
-78
No files matched your search
@@ -0,0 +1,374 @@
|
||||
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 只写:存为 secret,响应/审计/日志不含明文;null 清除并删除 secret", async () => {
|
||||
const plain = "sk-live-super-secret-0001";
|
||||
const r = await json(await admin("PATCH", "/settings", { pi: { apiKey: plain, model: "claude-x" } }));
|
||||
const ref = r.data.pi.apiKeySecretRef;
|
||||
expect(ref).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(JSON.stringify(r)).not.toContain(plain);
|
||||
expect(await h.ctx.secrets.resolve(ref)).toBe(plain);
|
||||
// 再改一次:原地轮换,ref 不变
|
||||
const r2 = await json(await admin("PATCH", "/settings", { pi: { apiKey: `${plain}-2` } }));
|
||||
expect(r2.data.pi.apiKeySecretRef).toBe(ref);
|
||||
expect(await h.ctx.secrets.resolve(ref)).toBe(`${plain}-2`);
|
||||
const audits = await sql<{
|
||||
payload: unknown;
|
||||
}>`select payload from audit_event where action = 'settings.update'`.execute(h.db);
|
||||
expect(JSON.stringify(audits.rows)).not.toContain(plain);
|
||||
const r3 = await json(await admin("PATCH", "/settings", { pi: { apiKey: null } }));
|
||||
expect(r3.data.pi.apiKeySecretRef).toBeNull();
|
||||
await expect(h.ctx.secrets.resolve(ref)).rejects.toThrow(/不存在/);
|
||||
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);
|
||||
});
|
||||
|
||||
it("PUT /sites/{id}/credentials:新建/替换账号凭证,不回显,旧 secret 删除;未知站点 404", async () => {
|
||||
const site = await sql<{ id: string }>`insert into site (key, name, adapter, base_url)
|
||||
values ('audiences', '观众', 'nexusphp', 'https://audiences.me') returning id`.execute(h.db);
|
||||
const siteId = site.rows[0]!.id;
|
||||
const r1 = await json(
|
||||
await admin("PUT", `/sites/${siteId}/credentials`, {
|
||||
authKind: "cookie",
|
||||
credential: "c_secure_uid=1; pass=abc",
|
||||
}),
|
||||
);
|
||||
expect(r1.data).toMatchObject({ created: true, label: "default", authKind: "cookie" });
|
||||
expect(JSON.stringify(r1)).not.toContain("pass=abc");
|
||||
expect(await h.ctx.secrets.resolve(r1.data.secretRef)).toBe("c_secure_uid=1; pass=abc");
|
||||
const r2 = await json(
|
||||
await admin("PUT", `/sites/${siteId}/credentials`, {
|
||||
authKind: "password",
|
||||
credential: "pw-123456",
|
||||
username: "me",
|
||||
}),
|
||||
);
|
||||
expect(r2.data).toMatchObject({ created: false, siteAccountId: r1.data.siteAccountId });
|
||||
await expect(h.ctx.secrets.resolve(r1.data.secretRef)).rejects.toThrow(/不存在/);
|
||||
const acc = await sql<{ auth_kind: string; user_info: { username?: string } }>`
|
||||
select auth_kind, user_info from site_account where id = ${r1.data.siteAccountId}`.execute(h.db);
|
||||
expect(acc.rows[0]).toMatchObject({ auth_kind: "password", user_info: { username: "me" } });
|
||||
expect(
|
||||
(
|
||||
await admin("PUT", "/sites/00000000-0000-4000-8000-000000000000/credentials", {
|
||||
authKind: "cookie",
|
||||
credential: "x",
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
noLeak("pass=abc");
|
||||
noLeak("pw-123456");
|
||||
});
|
||||
});
|
||||
|
||||
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=site_account"));
|
||||
expect(t.data.items.map((e: { action: string }) => e.action)).toEqual([
|
||||
"site_account.credentials_set",
|
||||
"site_account.credentials_set",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { type DB, migrateToLatest } from "@umt/db";
|
||||
import { migrateEngineSchema } from "@umt/tasks";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { vi } from "vitest";
|
||||
import { ConfigSchema } from "../../src/config.ts";
|
||||
import { createOutboxPort } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
import { createAuthSettingsModule } from "../../src/modules/auth-settings/index.ts";
|
||||
import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import { DEV_KEY, memoryLogger } from "../helpers.ts";
|
||||
|
||||
// 真实 PG 的 W2-A 测试夹具:独立业务 schema + 独立引擎 schema;任务引擎用假对象(只测 cancel 透传)。
|
||||
|
||||
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
export const MASTER_KEY = "test-master-key-0123456789";
|
||||
|
||||
export async function pgAvailable() {
|
||||
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
|
||||
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
|
||||
try {
|
||||
await c.connect();
|
||||
await c.end();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely 迁移器会反射库里所有 schema 的表;别的测试文件同时 drop schema 时反射会报"schema 不存在"。
|
||||
* 同一进程内的测试文件用 PG 咨询锁把"迁移 / 删 schema"串行化,另加重试兜底(其它进程的测试也在用同一个库)。
|
||||
*/
|
||||
const SCHEMA_LOCK = 0x756d7431;
|
||||
export async function withSchemaLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
try {
|
||||
await c.query("select pg_advisory_lock($1)", [SCHEMA_LOCK]);
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (attempt >= 4 || !/does not exist/.test(String((err as Error)?.message))) throw err;
|
||||
await new Promise((r) => setTimeout(r, 200 * attempt));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await c.query("select pg_advisory_unlock($1)", [SCHEMA_LOCK]).catch(() => {});
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function adminQuery(q: string) {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
try {
|
||||
await c.query(q);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHarness(opts: { engine?: boolean } = {}) {
|
||||
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const schema = `w2a_test_${uniq}`;
|
||||
const engineSchema = `${schema}_ow`;
|
||||
await adminQuery(`create schema ${schema}`);
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 5, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await withSchemaLock(async () => {
|
||||
await migrateToLatest(db, { migrationTableSchema: schema });
|
||||
if (opts.engine) await migrateEngineSchema(PG_URL, engineSchema);
|
||||
});
|
||||
const { logger, lines } = memoryLogger();
|
||||
const bus = new EventBus(100);
|
||||
const config = ConfigSchema.parse({
|
||||
DATABASE_URL: PG_URL,
|
||||
SECRET_MASTER_KEY: MASTER_KEY,
|
||||
NODE_ENV: "development",
|
||||
DEV_ADMIN_KEY: DEV_KEY,
|
||||
});
|
||||
const cancel = vi.fn(async (_runId: string, _reason?: string) => true);
|
||||
const ctx = {
|
||||
role: "all",
|
||||
db,
|
||||
config,
|
||||
logger,
|
||||
events: bus,
|
||||
engine: { cancel } as unknown as ServerContext["engine"],
|
||||
outbox: createOutboxPort(),
|
||||
secrets: createSecretStore({ db, masterKey: MASTER_KEY }),
|
||||
settings: createSettingsReader({ db, logger, events: bus }),
|
||||
capabilities: createCapabilityRegistry({ logger }),
|
||||
} as ServerContext;
|
||||
const mod = createAuthSettingsModule({ setupTokenOnStart: false, engineSchema });
|
||||
const app = createApp({ config, logger, bus, db, role: "all", version: "0.0.0-test", modules: [mod], ctx });
|
||||
return {
|
||||
db,
|
||||
app,
|
||||
ctx,
|
||||
bus,
|
||||
lines,
|
||||
cancel,
|
||||
schema,
|
||||
engineSchema,
|
||||
async close() {
|
||||
await db.destroy();
|
||||
await withSchemaLock(async () => {
|
||||
await adminQuery(`drop schema if exists ${schema} cascade`);
|
||||
await adminQuery(`drop schema if exists ${engineSchema} cascade`);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
export type Harness = Awaited<ReturnType<typeof createHarness>>;
|
||||
|
||||
/** 极简 cookie jar:记住 Set-Cookie,请求时带上;写方法自动带 CSRF 头 */
|
||||
export class Browser {
|
||||
cookies = new Map<string, string>();
|
||||
constructor(
|
||||
private readonly app: Harness["app"],
|
||||
readonly ip = "203.0.113.10",
|
||||
) {}
|
||||
|
||||
absorb(res: Response) {
|
||||
for (const sc of res.headers.getSetCookie()) {
|
||||
const [pair] = sc.split(";");
|
||||
const i = pair!.indexOf("=");
|
||||
const k = pair!.slice(0, i).trim();
|
||||
const v = pair!.slice(i + 1).trim();
|
||||
if (!v || /max-age=0/i.test(sc)) this.cookies.delete(k);
|
||||
else this.cookies.set(k, decodeURIComponent(v));
|
||||
}
|
||||
}
|
||||
|
||||
async req(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opts: { csrf?: boolean; headers?: Record<string, string> } = {},
|
||||
) {
|
||||
const headers: Record<string, string> = { "x-real-ip": this.ip, ...opts.headers };
|
||||
if (this.cookies.size)
|
||||
headers.cookie = [...this.cookies].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("; ");
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
const csrf = this.cookies.get("umt_csrf");
|
||||
if (method !== "GET" && opts.csrf !== false && csrf) headers["x-csrf-token"] = csrf;
|
||||
const res = await this.app.request(`/api/v1${path}`, {
|
||||
method,
|
||||
headers,
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
this.absorb(res);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bearer 调用 */
|
||||
export function bearer(app: Harness["app"], key: string) {
|
||||
return (method: string, path: string, body?: unknown) =>
|
||||
app.request(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${key}`,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureSetupToken,
|
||||
MAX_FAILURES_PER_ACCOUNT,
|
||||
MAX_FAILURES_PER_IP,
|
||||
} from "../../src/modules/auth-settings/auth-service.ts";
|
||||
import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await sql`delete from login_attempt`.execute(h.db);
|
||||
});
|
||||
|
||||
const login = (b: Browser, password = PASSWORD, extra: Record<string, unknown> = {}) =>
|
||||
b.req("POST", "/auth/login", { username: "admin", password, ...extra });
|
||||
|
||||
it("未初始化:session 探测免鉴权;setup token 只打印一次到日志;错 token 401;并发初始化只有一个成功", async () => {
|
||||
const b = new Browser(h.app);
|
||||
const s0 = await json(await b.req("GET", "/auth/session"));
|
||||
expect(s0.data).toMatchObject({ initialized: false, authenticated: false, actor: null });
|
||||
expect((await login(b)).status).toBe(412);
|
||||
|
||||
const token = await ensureSetupToken(h.ctx);
|
||||
expect(token).toBeTruthy();
|
||||
expect(h.lines.filter((l) => l.includes(token!))).toHaveLength(1);
|
||||
// 库里只有哈希
|
||||
const row = await sql<{ setup_token_hash: string }>`select setup_token_hash from admin_user`.execute(h.db);
|
||||
expect(row.rows[0]!.setup_token_hash).not.toContain(token!);
|
||||
|
||||
const bad = await b.req("POST", "/auth/setup", {
|
||||
setupToken: "x".repeat(32),
|
||||
username: "admin",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(bad.status).toBe(401);
|
||||
// 密码太短 → 422
|
||||
expect(
|
||||
(await b.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: "short" })).status,
|
||||
).toBe(422);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
new Browser(h.app, "198.51.100.1").req("POST", "/auth/setup", {
|
||||
setupToken: token,
|
||||
username: "admin",
|
||||
password: PASSWORD,
|
||||
}),
|
||||
new Browser(h.app, "198.51.100.2").req("POST", "/auth/setup", {
|
||||
setupToken: token,
|
||||
username: "admin",
|
||||
password: PASSWORD,
|
||||
}),
|
||||
]);
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 409]);
|
||||
const ok = r1.status === 200 ? r1 : r2;
|
||||
const setCookies = ok.headers.getSetCookie();
|
||||
const sess = setCookies.find((c) => c.startsWith("umt_session="))!;
|
||||
expect(sess).toMatch(/HttpOnly/i);
|
||||
expect(sess).toMatch(/SameSite=Lax/i);
|
||||
expect(setCookies.find((c) => c.startsWith("umt_csrf="))).not.toMatch(/HttpOnly/i);
|
||||
// 已初始化后不再生成 token
|
||||
expect(await ensureSetupToken(h.ctx)).toBeNull();
|
||||
const audits = await sql<{
|
||||
n: number;
|
||||
}>`select count(*)::int as n from audit_event where action = 'admin.setup'`.execute(h.db);
|
||||
expect(audits.rows[0]!.n).toBe(1);
|
||||
});
|
||||
|
||||
it("登录:错密码 401;正确密码下发 cookie;会话可访问 admin 端点;未登录 401", async () => {
|
||||
const b = new Browser(h.app);
|
||||
expect((await login(b, "wrong-password-1")).status).toBe(401);
|
||||
expect((await b.req("GET", "/settings")).status).toBe(401);
|
||||
const res = await login(b);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await json(res)).data).toMatchObject({
|
||||
status: "ok",
|
||||
session: { authenticated: true, actor: { type: "admin", name: "admin" } },
|
||||
});
|
||||
const s = await json(await b.req("GET", "/auth/session"));
|
||||
expect(s.data.authenticated).toBe(true);
|
||||
expect(s.data.expiresAt).toBeTruthy();
|
||||
expect((await b.req("GET", "/settings")).status).toBe(200);
|
||||
// whoami 在 cookie 会话下同样可用
|
||||
expect((await json(await b.req("GET", "/auth/whoami"))).data.kind).toBe("admin_session");
|
||||
// 库里只存 sha256
|
||||
const raw = b.cookies.get("umt_session")!;
|
||||
const rows = await sql<{
|
||||
n: number;
|
||||
}>`select count(*)::int as n from admin_session where session_hash = ${raw}`.execute(h.db);
|
||||
expect(rows.rows[0]!.n).toBe(0);
|
||||
});
|
||||
|
||||
it("CSRF:cookie 会话的写请求缺少或伪造 x-csrf-token → 403;带正确值通过;Bearer 不需要", async () => {
|
||||
const b = new Browser(h.app);
|
||||
await login(b);
|
||||
const patch = { automation: { enabled: true } };
|
||||
expect((await b.req("PATCH", "/settings", patch, { csrf: false })).status).toBe(403);
|
||||
expect(
|
||||
(await b.req("PATCH", "/settings", patch, { csrf: false, headers: { "x-csrf-token": "forged" } })).status,
|
||||
).toBe(403);
|
||||
expect((await b.req("PATCH", "/settings", patch)).status).toBe(200);
|
||||
expect((await bearer(h.app, DEV_KEY)("PATCH", "/settings", patch)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("限速:同 IP 失败 5 次后第 6 次(即使密码正确)→ 429,且被拦请求不落表", async () => {
|
||||
const b = new Browser(h.app, "203.0.113.77");
|
||||
for (let i = 0; i < MAX_FAILURES_PER_IP; i++) expect((await login(b, `bad-${i}-password`)).status).toBe(401);
|
||||
expect((await login(b)).status).toBe(429);
|
||||
expect((await login(b)).status).toBe(429);
|
||||
const n = await sql<{ n: number }>`select count(*)::int as n from login_attempt where ip = '203.0.113.77'`.execute(
|
||||
h.db,
|
||||
);
|
||||
expect(n.rows[0]!.n).toBe(MAX_FAILURES_PER_IP);
|
||||
// 其它 IP 不受影响
|
||||
expect((await login(new Browser(h.app, "203.0.113.78"))).status).toBe(200);
|
||||
});
|
||||
|
||||
it("限速:同账号跨 IP 累计失败 15 次 → 新 IP 也 429(防轮换 IP)", async () => {
|
||||
for (let i = 0; i < MAX_FAILURES_PER_ACCOUNT; i++) {
|
||||
expect((await login(new Browser(h.app, `192.0.2.${i + 1}`), `bad-${i}-pw`)).status).toBe(401);
|
||||
}
|
||||
expect((await login(new Browser(h.app, "192.0.2.99"))).status).toBe(429);
|
||||
});
|
||||
|
||||
it("登出吊销会话;过期会话 401", async () => {
|
||||
const b = new Browser(h.app);
|
||||
await login(b);
|
||||
const token = b.cookies.get("umt_session")!;
|
||||
expect((await b.req("POST", "/auth/logout")).status).toBe(200);
|
||||
expect(b.cookies.has("umt_session")).toBe(false);
|
||||
b.cookies.set("umt_session", token);
|
||||
expect((await b.req("GET", "/settings")).status).toBe(401);
|
||||
|
||||
const c = new Browser(h.app);
|
||||
await login(c);
|
||||
await sql`update admin_session set expires_at = now() - interval '1 second' where revoked_at is null`.execute(h.db);
|
||||
expect((await c.req("GET", "/settings")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("改密:校验旧密码,吊销其它会话、保留当前会话;API Key 不能改密", async () => {
|
||||
const a = new Browser(h.app);
|
||||
const b = new Browser(h.app);
|
||||
await login(a);
|
||||
await login(b);
|
||||
expect(
|
||||
(await a.req("POST", "/auth/password", { currentPassword: "nope-nope-nope", newPassword: "another password 1" }))
|
||||
.status,
|
||||
).toBe(401);
|
||||
const r = await a.req("POST", "/auth/password", { currentPassword: PASSWORD, newPassword: "another password 1" });
|
||||
expect(r.status).toBe(200);
|
||||
expect((await json(r)).data.revokedSessions).toBeGreaterThanOrEqual(1);
|
||||
expect((await a.req("GET", "/settings")).status).toBe(200);
|
||||
expect((await b.req("GET", "/settings")).status).toBe(401);
|
||||
expect((await login(new Browser(h.app))).status).toBe(401);
|
||||
expect((await login(new Browser(h.app), "another password 1")).status).toBe(200);
|
||||
// 改回来,后续用例用 PASSWORD
|
||||
expect(
|
||||
(await a.req("POST", "/auth/password", { currentPassword: "another password 1", newPassword: PASSWORD })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await bearer(h.app, DEV_KEY)("POST", "/auth/password", {
|
||||
currentPassword: PASSWORD,
|
||||
newPassword: "x".repeat(12),
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
const audits = await sql<{
|
||||
n: number;
|
||||
}>`select count(*)::int as n from audit_event where action = 'admin.password_change'`.execute(h.db);
|
||||
expect(audits.rows[0]!.n).toBe(2);
|
||||
});
|
||||
|
||||
it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => {
|
||||
const a = new Browser(h.app);
|
||||
await login(a);
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({ enabled: false, pending: false });
|
||||
const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data;
|
||||
expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
const secret: string = begin.secret;
|
||||
expect((await a.req("POST", "/auth/totp", { action: "enable", code: "000000" })).status).toBe(401);
|
||||
const code0 = totpAt(secret, totpCounter());
|
||||
expect((await a.req("POST", "/auth/totp", { action: "enable", code: code0 })).status).toBe(200);
|
||||
|
||||
const b = new Browser(h.app, "203.0.113.200");
|
||||
const step1 = await json(await login(b));
|
||||
expect(step1.data).toEqual({ status: "totp_required" });
|
||||
expect(b.cookies.has("umt_session")).toBe(false);
|
||||
// 同一时间窗的码已在 enable 时消费 → 重放失败;下一个窗口的码可用
|
||||
expect((await login(b, PASSWORD, { totpCode: code0 })).status).toBe(401);
|
||||
const next = totpAt(secret, totpCounter() + 1);
|
||||
expect((await login(b, PASSWORD, { totpCode: next })).status).toBe(200);
|
||||
expect((await login(new Browser(h.app, "203.0.113.201"), PASSWORD, { totpCode: next })).status).toBe(401);
|
||||
const fails = await sql<{
|
||||
n: number;
|
||||
}>`select count(*)::int as n from login_attempt where reason = 'bad_totp'`.execute(h.db);
|
||||
expect(fails.rows[0]!.n).toBe(2);
|
||||
|
||||
// 停用需要密码 + 新验证码(计数器已到 now+1,用 now+2 不在窗口内 → 这里直接把计数器回拨模拟时间流逝)
|
||||
await sql`update admin_user set totp_last_counter = totp_last_counter - 5`.execute(h.db);
|
||||
const code = totpAt(secret, totpCounter());
|
||||
expect((await a.req("POST", "/auth/totp", { action: "disable", password: "bad-password", code })).status).toBe(401);
|
||||
expect((await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code })).status).toBe(200);
|
||||
expect((await json(await login(new Browser(h.app, "203.0.113.202")))).data.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
base32Decode,
|
||||
base32Encode,
|
||||
hashPassword,
|
||||
totpAt,
|
||||
verifyPassword,
|
||||
verifyTotp,
|
||||
} from "../../src/modules/auth-settings/crypto.ts";
|
||||
import { createSecretStore, SecretError } from "../../src/modules/auth-settings/provided.ts";
|
||||
import { createHarness, type Harness, MASTER_KEY, pgAvailable } from "./harness.ts";
|
||||
|
||||
describe("密码学原语", () => {
|
||||
it("argon2id(hash-wasm)输出标准 PHC 串,校验正反", async () => {
|
||||
const h = await hashPassword("pässwörd-123");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=19456,t=2,p=1\$/);
|
||||
expect(await verifyPassword("pässwörd-123", h)).toBe(true);
|
||||
expect(await verifyPassword("password-123", h)).toBe(false);
|
||||
expect(await verifyPassword("x", "not-a-hash")).toBe(false);
|
||||
});
|
||||
|
||||
it("TOTP:RFC 6238 测试向量(SHA1)+ 防重放", () => {
|
||||
const secret = base32Encode(Buffer.from("12345678901234567890"));
|
||||
expect(base32Decode(secret).toString()).toBe("12345678901234567890");
|
||||
// RFC 6238 附录 B:T=59s → 94287082(8 位),6 位取后 6 位
|
||||
expect(totpAt(secret, 1)).toBe("287082");
|
||||
expect(totpAt(secret, Math.floor(1111111109 / 30))).toBe("081804");
|
||||
const now = 1_700_000_000_000;
|
||||
const c = Math.floor(now / 30_000);
|
||||
expect(verifyTotp(secret, totpAt(secret, c), null, now)).toBe(c);
|
||||
expect(verifyTotp(secret, totpAt(secret, c - 1), null, now)).toBe(c - 1);
|
||||
expect(verifyTotp(secret, totpAt(secret, c), c, now)).toBeNull();
|
||||
expect(verifyTotp(secret, totpAt(secret, c + 3), null, now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("SecretStore(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("create/resolve/replace(rotate)/remove 往返;库里无明文;每次加密盐与 nonce 不同", async () => {
|
||||
const s = h.ctx.secrets;
|
||||
const ref = await s.create({ purpose: "site_cookie", plaintext: "uid=1; pass=topsecret" });
|
||||
expect(await s.resolve(ref)).toBe("uid=1; pass=topsecret");
|
||||
const raw = await sql<{ ciphertext: Buffer; nonce: Buffer; kdf_params: { salt: string } }>`
|
||||
select ciphertext, nonce, kdf_params from secret where id = ${ref}`.execute(h.db);
|
||||
const before = raw.rows[0]!;
|
||||
expect(before.ciphertext.toString("utf8")).not.toContain("topsecret");
|
||||
await s.replace(ref, "uid=1; pass=rotated");
|
||||
expect(await s.resolve(ref)).toBe("uid=1; pass=rotated");
|
||||
const after = (
|
||||
await sql<{ nonce: Buffer; kdf_params: { salt: string }; rotated_at: Date | null }>`
|
||||
select nonce, kdf_params, rotated_at from secret where id = ${ref}`.execute(h.db)
|
||||
).rows[0]!;
|
||||
expect(after.nonce.equals(before.nonce)).toBe(false);
|
||||
expect(after.kdf_params.salt).not.toBe(before.kdf_params.salt);
|
||||
expect(after.rotated_at).toBeTruthy();
|
||||
await s.remove(ref);
|
||||
await expect(s.resolve(ref)).rejects.toBeInstanceOf(SecretError);
|
||||
});
|
||||
|
||||
it("主密钥不对 / 密文挪到别的行 / 改用途 → 解密失败,错误信息不含明文", async () => {
|
||||
const s = h.ctx.secrets;
|
||||
const a = await s.create({ purpose: "site_api_key", plaintext: "alpha-plain" });
|
||||
const b = await s.create({ purpose: "site_api_key", plaintext: "bravo-plain" });
|
||||
const other = createSecretStore({ db: h.db, masterKey: `${MASTER_KEY}-other` });
|
||||
await expect(other.resolve(a)).rejects.toThrow(/解密失败/);
|
||||
await sql`update secret set ciphertext = (select ciphertext from secret where id = ${a}),
|
||||
nonce = (select nonce from secret where id = ${a}), auth_tag = (select auth_tag from secret where id = ${a}),
|
||||
kdf_params = (select kdf_params from secret where id = ${a}) where id = ${b}`.execute(h.db);
|
||||
const err = await s.resolve(b).catch((e: Error) => e);
|
||||
expect(err).toBeInstanceOf(SecretError);
|
||||
expect(String((err as Error).message)).not.toMatch(/alpha|bravo/);
|
||||
await sql`update secret set purpose = 'other' where id = ${a}`.execute(h.db);
|
||||
await expect(s.resolve(a)).rejects.toThrow(/解密失败/);
|
||||
await expect(s.resolve("not-a-uuid")).rejects.toThrow(/不存在/);
|
||||
});
|
||||
|
||||
it("resolve 出的明文经 logger 记录时也不会以敏感字段名泄漏;SecretError 进日志只有 ref", async () => {
|
||||
const ref = await h.ctx.secrets.create({ purpose: "downloader_password", plaintext: "qb-pass-777" });
|
||||
const password = await h.ctx.secrets.resolve(ref);
|
||||
h.ctx.logger.info({ password, secretRef: ref }, "连接下载器");
|
||||
const err = await h.ctx.secrets.resolve("00000000-0000-4000-8000-000000000000").catch((e: Error) => e);
|
||||
h.ctx.logger.error({ err }, "取凭证失败");
|
||||
const out = h.lines.join("\n");
|
||||
expect(out).not.toContain("qb-pass-777");
|
||||
expect(out).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { PgEventBus } from "../../src/http/sse-pg.ts";
|
||||
import { DEV_KEY } from "../helpers.ts";
|
||||
import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts";
|
||||
|
||||
// SSE 多进程:两个 Deno 子进程(订阅者 / 发布者)通过 sse_event + LISTEN/NOTIFY 互通;
|
||||
// 再验证 API 进程的 GET /events 能推送另一个进程发布的事件,以及 ?after= 补读与裁剪后的 resync。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
function proc(args: string[]) {
|
||||
const child = spawn(process.execPath, ["run", "-A", "test/fixtures/sse-bus-proc.ts", ...args], {
|
||||
cwd: SERVER_DIR,
|
||||
env: { ...process.env, DATABASE_URL: PG_URL },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const lines: string[] = [];
|
||||
let buf = "";
|
||||
let err = "";
|
||||
child.stdout!.on("data", (d: Buffer) => {
|
||||
buf += d.toString();
|
||||
const parts = buf.split("\n");
|
||||
buf = parts.pop() ?? "";
|
||||
lines.push(...parts.filter(Boolean));
|
||||
});
|
||||
child.stderr!.on("data", (d: Buffer) => {
|
||||
err += d.toString();
|
||||
});
|
||||
const exited = new Promise<number>((resolve) => child.on("exit", (code) => resolve(code ?? -1)));
|
||||
return { child, lines, exited, stderr: () => err };
|
||||
}
|
||||
|
||||
async function until(cond: () => boolean, ms: number, what: string) {
|
||||
const deadline = Date.now() + ms;
|
||||
while (!cond()) {
|
||||
if (Date.now() > deadline) throw new Error(`等待超时:${what}`);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("PgEventBus 多进程(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let bus: PgEventBus;
|
||||
const channel = `umt_events_t${process.pid}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const children: ChildProcess[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
bus = await PgEventBus.start({
|
||||
db: h.db,
|
||||
connectionString: PG_URL,
|
||||
logger: h.ctx.logger,
|
||||
channel,
|
||||
retain: 30,
|
||||
pruneEvery: 1000,
|
||||
});
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
for (const c of children) c.kill();
|
||||
await bus?.stop();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("子进程 A 订阅、子进程 B 发布:A 按 seq 顺序收齐;本进程也能补读", async () => {
|
||||
const sub = proc(["sub", h.schema, channel, "20"]);
|
||||
children.push(sub.child);
|
||||
await until(() => sub.lines.includes("READY"), 30_000, "订阅进程就绪");
|
||||
const seenHere: number[] = [];
|
||||
const off = bus.subscribe((e) => seenHere.push(e.seq));
|
||||
const pub = proc(["pub", h.schema, channel, "20"]);
|
||||
children.push(pub.child);
|
||||
expect(await pub.exited).toBe(0);
|
||||
expect(await sub.exited).toBe(0);
|
||||
const events = sub.lines.filter((l) => l.startsWith("{")).map((l) => JSON.parse(l));
|
||||
expect(events).toHaveLength(20);
|
||||
expect(events.map((e) => e.payload.i)).toEqual([...Array(20).keys()]);
|
||||
const seqs = events.map((e) => e.seq as number);
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual(seqs);
|
||||
expect(new Set(events.map((e) => e.payload.pid)).size).toBe(1);
|
||||
await until(() => seenHere.length >= 20, 5_000, "本进程收到");
|
||||
off();
|
||||
const since = await bus.since(seqs[9]!);
|
||||
expect(since.events.map((e) => e.seq)).toEqual(seqs.slice(10));
|
||||
expect(since.gap).toBe(false);
|
||||
expect(since.latestSeq).toBe(seqs[19]);
|
||||
}, 60_000);
|
||||
|
||||
it("API 进程的 GET /events 推送其它进程发布的事件;裁剪后旧游标收到 resync", async () => {
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger: h.ctx.logger,
|
||||
bus,
|
||||
role: "api",
|
||||
version: "t",
|
||||
});
|
||||
const { latestSeq } = await bus.since(0);
|
||||
const ac = new AbortController();
|
||||
const res = await app.request(`/api/v1/events?after=${latestSeq}`, {
|
||||
headers: { authorization: `Bearer ${DEV_KEY}` },
|
||||
signal: ac.signal,
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
let text = "";
|
||||
const reading = (async () => {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) return;
|
||||
text += new TextDecoder().decode(value);
|
||||
}
|
||||
})();
|
||||
const pub = proc(["pub", h.schema, channel, "3"]);
|
||||
children.push(pub.child);
|
||||
expect(await pub.exited).toBe(0);
|
||||
await until(() => (text.match(/event: test\.ping/g) ?? []).length >= 3, 10_000, "SSE 推送");
|
||||
ac.abort();
|
||||
await reader.cancel().catch(() => {});
|
||||
await reading.catch(() => {});
|
||||
|
||||
// 裁剪到只剩 30 条后,after=1 已不可补读 → resync
|
||||
const more = proc(["pub", h.schema, channel, "40"]);
|
||||
children.push(more.child);
|
||||
expect(await more.exited).toBe(0);
|
||||
await bus.prune();
|
||||
const s = await bus.since(1);
|
||||
expect(s.gap).toBe(true);
|
||||
expect(s.events.length).toBe(30);
|
||||
const ac2 = new AbortController();
|
||||
const res2 = await app.request("/api/v1/events?after=1", {
|
||||
headers: { authorization: `Bearer ${DEV_KEY}` },
|
||||
signal: ac2.signal,
|
||||
});
|
||||
const r2 = res2.body!.getReader();
|
||||
const first = new TextDecoder().decode((await r2.read()).value);
|
||||
expect(first).toContain("event: system.resync");
|
||||
ac2.abort();
|
||||
await r2.cancel().catch(() => {});
|
||||
}, 60_000);
|
||||
|
||||
it("发布失败不抛(返回 null)", async () => {
|
||||
// BigInt 无法 JSON 序列化 → 发布内部出错
|
||||
await expect(bus.publish("x", { n: 1n })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { type Bootstrapped, bootstrap } from "../src/context.ts";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
import type { ServerModule } from "../src/modules/types.ts";
|
||||
import { type RunningWorker, startWorker } from "../src/worker/index.ts";
|
||||
import { withSchemaLock } from "./auth-settings/harness.ts";
|
||||
import { memoryLogger } from "./helpers.ts";
|
||||
|
||||
// 真实 PG:独立业务 schema + 独立引擎 schema,验证 bootstrap → worker → outbox → 模块任务 的整条装配。
|
||||
@@ -71,7 +72,7 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
|
||||
pool: new pg.Pool({ connectionString: url, max: 5, options: `-c search_path=${biz},public` }),
|
||||
}),
|
||||
});
|
||||
await migrateToLatest(db, { migrationTableSchema: biz });
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: biz }));
|
||||
await db
|
||||
.insertInto("settings")
|
||||
.values({ key: "cache", value: JSON.stringify({ hardThresholdPercent: 95, resumePercent: 90 }) })
|
||||
@@ -98,18 +99,20 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
|
||||
await worker?.stop();
|
||||
await booted?.engine.stop();
|
||||
await db?.destroy();
|
||||
const admin = new pg.Client({ connectionString: url });
|
||||
await admin.connect();
|
||||
await admin.query(`drop schema if exists ${biz} cascade`);
|
||||
await admin.query(`drop schema if exists ${ow} cascade`);
|
||||
await admin.end();
|
||||
await withSchemaLock(async () => {
|
||||
const admin = new pg.Client({ connectionString: url });
|
||||
await admin.connect();
|
||||
await admin.query(`drop schema if exists ${biz} cascade`);
|
||||
await admin.query(`drop schema if exists ${ow} cascade`);
|
||||
await admin.end();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("settings 读取合并缺省值", async () => {
|
||||
const cache = await booted.ctx.settings.get("cache");
|
||||
expect(cache).toMatchObject({ hardThresholdPercent: 95, resumePercent: 90, checkIntervalSec: 60 });
|
||||
expect((await booted.ctx.settings.all()).automation.enabled).toBe(true);
|
||||
await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/W2-A/);
|
||||
await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/不存在/);
|
||||
});
|
||||
|
||||
it("业务事务内 emit → 中继路由 → worker 执行模块任务;同幂等键只投一次;非法 payload 拒写", async () => {
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。
|
||||
// 用法:deno run -A test/fixtures/sse-bus-proc.ts <sub|pub> <schema> <channel> <n>
|
||||
import type { DB } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { PgEventBus } from "../../src/http/sse-pg.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
|
||||
const [mode, schema, channel, nRaw] = Deno.args;
|
||||
const n = Number(nRaw);
|
||||
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
const bus = await PgEventBus.start({
|
||||
db,
|
||||
connectionString: url,
|
||||
logger: createLogger({ level: "warn" }),
|
||||
channel: channel!,
|
||||
});
|
||||
|
||||
if (mode === "sub") {
|
||||
let got = 0;
|
||||
const done = new Promise<void>((resolve) => {
|
||||
bus.subscribe((e) => {
|
||||
console.log(JSON.stringify(e));
|
||||
if (++got >= n) resolve();
|
||||
});
|
||||
});
|
||||
console.log("READY");
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("timeout");
|
||||
Deno.exit(3);
|
||||
}, 20_000);
|
||||
await done;
|
||||
clearTimeout(timeout);
|
||||
} else {
|
||||
for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: Deno.pid });
|
||||
}
|
||||
await bus.stop();
|
||||
await db.destroy();
|
||||
@@ -5,11 +5,11 @@ import { DEV_KEY, testApp } from "./helpers.ts";
|
||||
describe("EventBus", () => {
|
||||
it("序号自增并按 after 补读,淘汰后标记 gap", () => {
|
||||
const bus = new EventBus(3);
|
||||
for (let i = 0; i < 5; i++) bus.publish("t", { i });
|
||||
for (let i = 0; i < 5; i++) bus.publishSync("t", { i });
|
||||
expect(bus.latestSeq).toBe(5);
|
||||
expect(bus.since(3).events.map((e) => e.seq)).toEqual([4, 5]);
|
||||
expect(bus.since(3).gap).toBe(false);
|
||||
expect(bus.since(1).gap).toBe(true);
|
||||
expect(bus.sinceSync(3).events.map((e) => e.seq)).toEqual([4, 5]);
|
||||
expect(bus.sinceSync(3).gap).toBe(false);
|
||||
expect(bus.sinceSync(1).gap).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@ describe("GET /api/v1/events", () => {
|
||||
|
||||
it("补读 after 之后的事件", async () => {
|
||||
const { app, bus } = testApp();
|
||||
bus.publish("a", 1);
|
||||
bus.publish("b", 2);
|
||||
bus.publishSync("a", 1);
|
||||
bus.publishSync("b", 2);
|
||||
const ac = new AbortController();
|
||||
const res = await app.request("/api/v1/events?after=1", {
|
||||
headers: { Authorization: `Bearer ${DEV_KEY}` },
|
||||
|
||||
Reference in new issue
Block a user