import { LOG_STREAM_EVENT, type LogEntry, LogPage, LogQuery } from "@umt/contracts"; import { sql } from "@umt/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createLogStreamPoller, createLogsModule, decodeCursor, encodeCursor } from "../../src/modules/logs/index.ts"; import { logQueries } from "../../src/modules/logs/query.ts"; import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; import { DEV_KEY, json } from "../helpers.ts"; // 日志模块:四个来源合并、筛选、游标分页、流(真实 PG + 独立引擎 schema)。 const hasPg = await pgAvailable(); describe("游标", () => { it("编码往返;无效游标当第一页", () => { const c = { us: 1_759_000_000_123_456, id: "engine_run:abc" }; expect(decodeCursor(encodeCursor(c))).toEqual(c); expect(decodeCursor("not-base64!!")).toBeNull(); expect(decodeCursor(undefined)).toBeNull(); }); it("type / level 接受逗号分隔多值,未知值 422", () => { expect(LogQuery.parse({ type: "task,auth", level: "error" })).toMatchObject({ type: ["task", "auth"], level: ["error"], limit: 50, }); expect(LogQuery.safeParse({ type: "nope" }).success).toBe(false); }); }); describe.skipIf(!hasPg)("日志聚合(真实 PG)", () => { let h: Harness; let admin: ReturnType; const base = Date.now() - 3600_000; const at = (min: number) => new Date(base + min * 60_000); const t = (n: string) => sql.raw(`"${h.engineSchema}".${n}`); const run = (id: string, status: string, name: string, input: unknown, created: Date, error: unknown = null) => sql`insert into ${t("workflow_runs")} (namespace_id, id, workflow_name, version, status, config, context, input, attempts, worker_id, available_at, started_at, finished_at, created_at, updated_at, error) values ('default', ${id}, ${name}, 'v1', ${status}, '{}'::jsonb, null, ${JSON.stringify(input)}::jsonb, 1, null, ${created}, ${created}, ${status === "running" ? null : new Date(created.getTime() + 2000)}, ${created}, ${created}, ${error ? JSON.stringify(error) : null}::jsonb)`.execute(h.db); const list = async (query: string) => { const res = await admin("GET", `/logs${query}`); expect(res.status, query).toBe(200); return LogPage.parse((await json(res)).data).items; }; let downloadId = ""; beforeAll(async () => { h = await createHarness({ engine: true, extraModules: [], }); const { createApp } = await import("../../src/http/app.ts"); const { createAuthSettingsModule } = await import("../../src/modules/auth-settings/index.ts"); h.app = createApp({ config: h.ctx.config, logger: h.ctx.logger, bus: h.bus, db: h.db, role: "all", version: "0.0.0-test", modules: [ createAuthSettingsModule({ setupTokenOnStart: false, engineSchema: h.engineSchema }), createLogsModule({ engineSchema: h.engineSchema, pollMs: 50 }), ], ctx: h.ctx, }) as Harness["app"]; admin = bearer(h.app, DEV_KEY); // 业务对象:下载任务 const inst = await sql<{ id: string }>`insert into downloader_instance (name, base_url) values ('qb', 'http://qb') returning id`.execute(h.db); const dl = await sql<{ id: string }>`insert into download_task (instance_id, torrent_hash, name) values (${inst.rows[0]!.id}, ${"a".repeat(40)}, '沙丘2 2160p') returning id`.execute(h.db); downloadId = dl.rows[0]!.id; // 任务:有业务对象的失败 / 完成;没有业务对象的(例行) await run("r-dl", "failed", "submitDownload", { input: { downloadTaskId: downloadId } }, at(1), { message: "qB 拒绝了种子", }); await run("r-dl2", "completed", "verifyAndImport", { input: { downloadTaskId: downloadId } }, at(2)); await run("r-sys", "completed", "snapshotSite", { input: {} }, at(3)); await run("r-running", "running", "submitDownload", { input: { downloadTaskId: downloadId } }, at(4)); // 定时任务:一条失败、一条成功(例行)、一条巡检 await sql`insert into scheduled_job (id, name, board_group, kind, enabled, default_enabled, schedule, default_schedule) values ('feeds.poll', 'RSS 抓取', 'collect', 'task', true, true, '{"type":"interval","everyMs":60000}'::jsonb, '{"type":"interval","everyMs":60000}'::jsonb), ('check.daily', '每日巡检', 'agent', 'agent_check', true, true, '{"type":"interval","everyMs":86400000}'::jsonb, '{"type":"interval","everyMs":86400000}'::jsonb)`.execute(h.db); await sql`insert into scheduled_job_run (job_id, trigger, status, error, started_at, finished_at, duration_ms) values ('feeds.poll', 'schedule', 'failed', '站点超时', ${at(5)}, ${at(5)}, 1200), ('feeds.poll', 'schedule', 'ok', null, ${at(6)}, ${at(6)}, 300), ('check.daily', 'schedule', 'ok', null, ${at(7)}, ${at(7)}, 9000)`.execute(h.db); // 智能体后台调查(trigger=event)与用户会话(不进日志的 auto) await sql`insert into agent_run (trigger, goal, status, summary, created_at, started_at, finished_at) values ('event', '调查下载为什么失败', 'succeeded', '种子已被站点删除', ${at(8)}, ${at(8)}, ${at(9)}), ('user', '帮我找沙丘', 'succeeded', null, ${at(8)}, ${at(8)}, ${at(9)})`.execute(h.db); // 审计:登录成功、登录失败、设置修改、智能体操作被拒 await sql`insert into audit_event (at, actor_type, actor_id, action, target_type, target_id, payload) values (${at(10)}, 'user', 'admin', 'admin.login_success', 'admin_session', 's1', '{"ip":"10.0.0.2"}'::jsonb), (${at(11)}, 'system', null, 'admin.login_failed', 'admin_user', null, '{"username":"admin","ip":"10.0.0.9"}'::jsonb), (${at(12)}, 'user', 'admin', 'settings.update', 'settings', 'cache', '{"keys":["cache"],"token":"sk-secret"}'::jsonb), (${at(13)}, 'agent', 'run-1', 'capability.denied', 'download_task', ${downloadId}, '{}'::jsonb)`.execute(h.db); }, 60_000); afterAll(async () => { await h?.close(); }); it("四个来源合并、按时间倒序;例行记录缺省不列", async () => { const items = await list("?limit=100"); expect(items.map((e) => e.id.split(":")[0])).toEqual( expect.arrayContaining(["engine_run", "schedule_run", "agent_run", "audit_event"]), ); const times = items.map((e) => Date.parse(e.at)); expect([...times].sort((a, b) => b - a)).toEqual(times); const refs = items.map((e) => e.refId); // 没有业务对象的引擎运行、成功的普通定时任务运行、用户会话都不在 expect(refs).not.toContain("r-sys"); expect(items.filter((e) => e.source === "schedule_run").map((e) => e.status)).toEqual(["ok", "failed"]); expect(items.filter((e) => e.source === "agent_run")).toHaveLength(1); // system=1 时列出 const all = await list("?limit=100&system=1"); expect(all.map((e) => e.refId)).toContain("r-sys"); expect(all.filter((e) => e.source === "schedule_run")).toHaveLength(3); }); it("各来源的类型、级别、标题、对象", async () => { const items = await list("?limit=100"); const by = (ref: string) => items.find((e) => e.refId === ref)!; expect(by("r-dl")).toMatchObject({ type: "task", level: "error", title: "沙丘2 2160p", message: "qB 拒绝了种子", object: { type: "download_task", id: downloadId, label: "沙丘2 2160p" }, extra: { workflowName: "submitDownload", businessKind: "download" }, }); expect(by("r-dl").durationMs).toBe(2000); expect(by("r-running")).toMatchObject({ level: "running", finishedAt: null }); const failedRun = items.find((e) => e.source === "schedule_run" && e.status === "failed")!; expect(failedRun).toMatchObject({ type: "auto", level: "error", title: "RSS 抓取", message: "站点超时", object: { type: "scheduled_job", id: "feeds.poll" }, durationMs: 1200, }); const check = items.find((e) => e.source === "schedule_run" && e.status === "ok")!; expect(check).toMatchObject({ title: "每日巡检", level: "success" }); expect(items.find((e) => e.source === "agent_run")).toMatchObject({ type: "auto", level: "success", title: "调查下载为什么失败", message: "种子已被站点删除", }); const audits = items.filter((e) => e.source === "audit_event"); expect(audits.map((e) => [e.status, e.type, e.level])).toEqual([ ["capability.denied", "audit", "warn"], ["settings.update", "audit", "info"], ["admin.login_failed", "auth", "warn"], ["admin.login_success", "auth", "success"], ]); expect(audits.find((e) => e.status === "admin.login_failed")!.message).toBe("admin · 10.0.0.9"); // 审计负载脱敏 const settings = audits.find((e) => e.status === "settings.update")!; expect(JSON.stringify(settings.extra)).not.toContain("sk-secret"); }); it("筛选:类型、级别、关键词、对象、时间", async () => { expect((await list("?type=auth")).map((e) => e.status)).toEqual(["admin.login_failed", "admin.login_success"]); expect((await list("?type=auto")).every((e) => e.type === "auto")).toBe(true); const errors = await list("?level=error"); expect(errors.map((e) => e.refId).sort()).toEqual( ["r-dl", errors.find((e) => e.source === "schedule_run")!.refId].sort(), ); expect((await list("?level=warn,running")).map((e) => e.level).sort()).toEqual(["running", "warn", "warn"]); // 关键词:任务按业务标题、审计按负载、定时任务按错误 expect((await list("?q=沙丘")).map((e) => e.refId)).toEqual(expect.arrayContaining(["r-dl", "r-dl2"])); expect((await list("?q=10.0.0.9")).map((e) => e.status)).toEqual(["admin.login_failed"]); expect((await list("?q=超时")).map((e) => e.source)).toEqual(["schedule_run"]); // %、_ 按字面匹配 expect(await list("?q=%25")).toEqual([]); // 对象:同一个下载任务的任务与审计都列出 const obj = await list(`?objectType=download_task&objectId=${downloadId}`); expect(obj.map((e) => e.refId)).toEqual(expect.arrayContaining(["r-dl", "r-dl2", "r-running"])); expect(obj.some((e) => e.status === "capability.denied")).toBe(true); // 按对象筛选时自动包含例行记录 const job = await list("?objectType=scheduled_job&objectId=feeds.poll"); expect(job.map((e) => e.status)).toEqual(["ok", "failed"]); // 时间 const recent = await list(`?from=${encodeURIComponent(at(10).toISOString())}`); expect(recent.every((e) => Date.parse(e.at) >= at(10).getTime())).toBe(true); expect(recent.map((e) => e.source)).toEqual(Array(4).fill("audit_event")); const older = await list(`?to=${encodeURIComponent(at(2).toISOString())}`); expect(older.map((e) => e.refId)).toEqual(["r-dl"]); }); it("游标分页:逐页拿到与一次取全相同的序列,不重不漏", async () => { const full = await list("?limit=200&system=1"); const seen: string[] = []; let cursor: string | null = null; for (let i = 0; i < 50; i++) { const res = await admin("GET", `/logs?limit=3&system=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`); const page = LogPage.parse((await json(res)).data); seen.push(...page.items.map((e) => e.id)); cursor = page.nextCursor; if (!cursor) break; } expect(seen).toEqual(full.map((e) => e.id)); expect(new Set(seen).size).toBe(seen.length); }); it("流:首轮不推已有日志;新日志推出;状态变化再推一次", async () => { let now = Date.now(); const poller = createLogStreamPoller( logQueries(h.db, { engineSchema: h.engineSchema }), {}, { since: new Date(now), now: () => now, }, ); expect(await poller.poll()).toEqual([]); const created = new Date(now + 1000); now += 2000; await run("r-new", "running", "submitDownload", { input: { downloadTaskId: downloadId } }, created); await sql`insert into audit_event (at, actor_type, action, payload) values (${new Date(now - 500)}, 'user', 'api_key.create', '{}'::jsonb)`.execute(h.db); const pushed = await poller.poll(); expect(pushed.map((e) => e.refId === "r-new" || e.status === "api_key.create")).toEqual([true, true]); // 正序:旧的在前 expect(Date.parse(pushed[0]!.at)).toBeLessThanOrEqual(Date.parse(pushed[1]!.at)); expect(await poller.poll()).toEqual([]); await sql`update ${t("workflow_runs")} set status = 'completed', finished_at = ${new Date(now)} where id = 'r-new'`.execute( h.db, ); const updated = await poller.poll(); expect(updated).toHaveLength(1); expect(updated[0]).toMatchObject({ refId: "r-new", level: "success" }); }); it("流接口:SSE 推 log.entry,筛选生效", async () => { const ctl = new AbortController(); const since = new Date().toISOString(); const res = await h.app.request(`/api/v1/logs/stream?type=auth&since=${encodeURIComponent(since)}`, { headers: { authorization: `Bearer ${DEV_KEY}` }, signal: ctl.signal, }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/event-stream"); const reader = res.body!.getReader(); const decoder = new TextDecoder(); let buf = ""; const readUntil = async (pred: (s: string) => boolean) => { const deadline = Date.now() + 10_000; while (!pred(buf) && Date.now() < deadline) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value); } }; await readUntil((s) => s.includes("event: ready")); // 一条不匹配(审计)、一条匹配(登录) await sql`insert into audit_event (actor_type, action, payload) values ('user', 'settings.update', '{}'::jsonb), ('system', 'admin.login_failed', '{"username":"x","ip":"1.2.3.4"}'::jsonb)`.execute(h.db); await readUntil((s) => s.includes(LOG_STREAM_EVENT) && s.includes("1.2.3.4")); ctl.abort(); await reader.cancel().catch(() => {}); const events = buf .split("\n\n") .filter((b) => b.includes(`event: ${LOG_STREAM_EVENT}`)) .map( (b) => JSON.parse( b .split("\n") .find((l) => l.startsWith("data: "))! .slice(6), ) as LogEntry, ); expect(events.length).toBeGreaterThanOrEqual(1); expect(events.every((e) => e.type === "auth")).toBe(true); expect(events.some((e) => e.message === "x · 1.2.3.4")).toBe(true); }); it("没有引擎 schema 时只查其它来源", async () => { const q = logQueries(h.db, { engineSchema: "no_such_engine" }); const page = await q.list({ limit: 50 }); expect(page.items.length).toBeGreaterThan(0); expect(page.items.some((e) => e.source === "engine_run")).toBe(false); }); });