Files
UHD_Media_Tool/apps/server/test/auth-settings/sse-pg.test.ts
T
rootandClaude Opus 5.5 ff230e5da7 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]>
2026-09-27 19:59:54 +08:00

148 lines
5.4 KiB
TypeScript

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();
});
});