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:
rootandClaude Opus 5.5 committed 2026-09-27 19:59:54 +08:00
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);
});
});
});
+176
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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();
+6 -6
View File
@@ -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}` },