Files
UHD_Media_Tool/apps/server/test/logs/logs.test.ts
T

300 lines
15 KiB
TypeScript

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