feat(hub): 事件去重(A-4) + 审计写入路径(A-8)

A-4 FeishuEventReceipt:
- 新增 FeishuEventReceipt 表(eventId @unique),lark ws 至少一次投递的幂等兜底
- MessageReceiveEvent 加 header.event_id 字段
- trigger 入口查重:已存在的 event_id → 跳过(不建第二次 run)
- resetDb 加入 FeishuEventReceipt

A-8 审计写入:
- AuditEntry 加 projectId(权限拒绝/slash 命令等无 run 的审计点可定位)
- 新建 src/audit.ts:writeAudit(prisma, entry) best-effort 写入(吞错,不阻断 trigger)
- trigger 五个审计点:trigger.denied / trigger.role_denied / run.created / run.lock_race / run.finished / run.failed
- 新增 audit unit 测试(3) + trigger 集成测试(去重 + 审计,2)

59 tests 全过。
This commit is contained in:
2026-07-07 21:34:15 +08:00
parent a2c8fa8eaf
commit f8c3e16a52
9 changed files with 261 additions and 7 deletions
+1
View File
@@ -26,6 +26,7 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"FeishuEventReceipt",
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
+41 -1
View File
@@ -8,8 +8,10 @@ import type { ModelFactory } from "../../src/agent/runner.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): MessageReceiveEvent {
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent {
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
return {
header,
message: {
message_id: "m_" + Math.random().toString(36).slice(2),
chat_id: chatId,
@@ -248,6 +250,44 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
});
});
it("dedups a redelivered event by event_id (no second run)", async () => {
await seedProject("proj-13", "chat-13");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
// First delivery: processes normally.
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
// Redelivery of the same event_id: must not create a second run.
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
const runs2 = await prisma.agentRun.findMany();
expect(runs2).toHaveLength(1);
// Only one event receipt row.
const receipts = await prisma.feishuEventReceipt.findMany();
expect(receipts).toHaveLength(1);
expect(receipts[0]?.eventId).toBe("evt-dedup-1");
});
it("writes audit entries across the run lifecycle", async () => {
await seedProject("proj-14", "chat-14");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const audit = await prisma.auditEntry.findMany();
expect(audit.length).toBeGreaterThanOrEqual(2);
});
const audit = await prisma.auditEntry.findMany({ orderBy: { createdAt: "asc" } });
expect(audit.some((a) => a.action === "run.created")).toBe(true);
expect(audit.some((a) => a.action === "run.finished")).toBe(true);
});
});
afterAll(async () => {
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { writeAudit } from "../../src/audit.js";
/// Minimal mock: only the surface `writeAudit` touches. Cast through unknown
/// so we don't have to implement the rest of the PrismaClient surface — this
/// is a unit test, not a DB test.
interface AuditEntryMock {
create: ReturnType<typeof vi.fn>;
}
function mockPrisma(): PrismaClient & { auditEntry: AuditEntryMock } {
return { auditEntry: { create: vi.fn() } } as unknown as PrismaClient & {
auditEntry: AuditEntryMock;
};
}
describe("writeAudit (A-8 audit write path)", () => {
it("creates a row with all fields set", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockResolvedValue({ id: "aud-1" });
await writeAudit(prisma, {
runId: "run-1",
projectId: "proj-1",
actorUserId: "user-1",
action: "run.triggered",
metadata: { source: "feishu" },
});
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
data: {
runId: "run-1",
projectId: "proj-1",
actorUserId: "user-1",
action: "run.triggered",
metadata: { source: "feishu" },
},
});
});
it("creates a row with only required fields (action + metadata)", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockResolvedValue({ id: "aud-2" });
await writeAudit(prisma, {
action: "permission.denied",
metadata: { reason: "no-edit-role" },
});
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
data: {
action: "permission.denied",
metadata: { reason: "no-edit-role" },
},
});
});
it("swallows a prisma error (best-effort, must not throw)", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockRejectedValue(new Error("db down"));
// Must not throw — audit is observability, not control.
await expect(
writeAudit(prisma, {
action: "run.triggered",
metadata: {},
}),
).resolves.toBeUndefined();
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
});
});