feat(server): 日志模块——任务 / 定时与自动 / 登录 / 审计合并查询与 SSE 流,登录失败留审计
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
01c68f9771
commit
744252b969
8 files changed
+823
-2
No files matched your search
@@ -0,0 +1,299 @@
|
||||
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<typeof bearer>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user