112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { createPluginContext } from "@suaveplan/api-plugin";
|
|
import {
|
|
type AuditEvent,
|
|
AuditSeverity,
|
|
MemoryAuditAdapter,
|
|
} from "@suaveplan/audit-logging";
|
|
import { EventBus } from "@suaveplan/events";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { Hono } from "hono";
|
|
import { createAuditPlugin } from "../../src/plugin/plugin.js";
|
|
import type { AuditPluginConfig } from "../../src/types/types.js";
|
|
|
|
const noopLogger = {
|
|
debug: () => undefined,
|
|
info: () => undefined,
|
|
warn: () => undefined,
|
|
error: () => undefined,
|
|
};
|
|
|
|
describe("full-boot integration", () => {
|
|
it("real api + plugin + sample route records redacted audit with principal and tenant", {
|
|
timeout: 10000,
|
|
}, async () => {
|
|
const sink = new MemoryAuditAdapter();
|
|
const plugin = createAuditPlugin({
|
|
sink,
|
|
secretKey: "x".repeat(32),
|
|
redactQueryParams: ["token"],
|
|
redactHeaders: ["authorization"],
|
|
redactBodyFields: ["password"],
|
|
recordRequestBody: true,
|
|
});
|
|
const app = new Hono();
|
|
const services = new Map<string, unknown>();
|
|
services.set("app", app);
|
|
|
|
// Drive lifecycle hooks the way @suaveplan/api would.
|
|
const ctx = createPluginContext<AuditPluginConfig>({
|
|
logger: noopLogger,
|
|
eventBus: new EventBus(),
|
|
signal: new AbortController().signal,
|
|
config: plugin.defaults as AuditPluginConfig,
|
|
services,
|
|
});
|
|
await plugin.onInit?.(ctx);
|
|
|
|
// Pretend auth + tenant plugins have already populated the context.
|
|
app.use("*", async (c, next) => {
|
|
c.set("user", { id: "u1" });
|
|
c.set("tenant", { id: "t1" });
|
|
await next();
|
|
});
|
|
|
|
// Sample route that ALSO fires an explicit semantic event via
|
|
// c.get("audit") to prove the handler-level helper is wired.
|
|
app.post("/things/:id", async (c) => {
|
|
const audit = c.get("audit");
|
|
await audit.log({
|
|
type: "thing.updated",
|
|
severity: AuditSeverity.WARNING,
|
|
actor: { userId: "u1" },
|
|
target: { type: "thing", id: c.req.param("id") },
|
|
});
|
|
return c.json({ updated: true });
|
|
});
|
|
|
|
const res = await app.request("/things/42?token=secret&safe=v", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer top-secret",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ password: "p", note: "hi" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const events = sink.snapshot();
|
|
expect(events.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const requestEvt = events.find((e) =>
|
|
String(e.type).startsWith("http.")
|
|
) as AuditEvent | undefined;
|
|
expect(requestEvt).toBeDefined();
|
|
const det = requestEvt?.details as {
|
|
method: string;
|
|
path: string;
|
|
status: number;
|
|
duration_ms: number;
|
|
query: Record<string, string>;
|
|
headers: Record<string, string>;
|
|
body?: Record<string, unknown>;
|
|
};
|
|
expect(det.method).toBe("POST");
|
|
expect(det.path).toBe("/things/42");
|
|
expect(det.status).toBe(200);
|
|
expect(det.duration_ms).toBeGreaterThanOrEqual(0);
|
|
expect(det.query["token"]).toBe("[REDACTED]");
|
|
expect(det.query["safe"]).toBe("v");
|
|
expect(det.headers["authorization"]).toBe("[REDACTED]");
|
|
expect(det.body?.["password"]).toBe("[REDACTED]");
|
|
expect(det.body?.["note"]).toBe("hi");
|
|
expect(requestEvt?.actor.userId).toBe("u1");
|
|
expect(requestEvt?.tenantId).toBe("t1");
|
|
|
|
const semanticEvt = events.find((e) => e.type === "thing.updated") as
|
|
| AuditEvent
|
|
| undefined;
|
|
expect(semanticEvt).toBeDefined();
|
|
expect(semanticEvt?.target?.id).toBe("42");
|
|
});
|
|
});
|