Initial Commit
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) walk(full, out);
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const missing: string[] = [];
|
||||
for (const f of walk(SRC)) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
if (f.endsWith(".spec-conformance.test.ts")) continue;
|
||||
if (f.endsWith(".types.ts")) continue;
|
||||
if (f.endsWith("/index.ts")) continue;
|
||||
const md = f.replace(/\.ts$/, ".md");
|
||||
if (!existsSync(md)) missing.push(relative(ROOT, md));
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("every subpath export resolves to a real source file", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entries = pkg.exports as Record<string, unknown>;
|
||||
const missing: string[] = [];
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (key === "./package.json") continue;
|
||||
// The value is either string OR { import: { default: "..." } }
|
||||
const distPath =
|
||||
typeof value === "object" && value !== null
|
||||
? (
|
||||
value as {
|
||||
import?: { default?: string };
|
||||
}
|
||||
).import?.default
|
||||
: (value as string);
|
||||
if (typeof distPath !== "string") continue;
|
||||
const srcPath = distPath
|
||||
.replace(/^\.\/dist\//, "./src/")
|
||||
.replace(/\.js$/, ".ts");
|
||||
const abs = join(ROOT, srcPath);
|
||||
if (!existsSync(abs)) missing.push(`${key} → ${srcPath}`);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createAuditMiddleware } from "../../src/middleware/audit-middleware.js";
|
||||
import type { AuditPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopHostLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic Mulberry32 RNG so the test is reproducible across
|
||||
* Bun + Node runtime engines.
|
||||
*/
|
||||
function mulberry32(seed: number): () => number {
|
||||
let t = seed >>> 0;
|
||||
return () => {
|
||||
t = (t + 0x6d2b79f5) >>> 0;
|
||||
let r = t;
|
||||
r = Math.imul(r ^ (r >>> 15), r | 1);
|
||||
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
describe("sampling integration", () => {
|
||||
it("50% sample rate over 1000 requests sits in [400, 600] band", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const config: AuditPluginConfig = {
|
||||
sink: storage,
|
||||
secretKey: "x".repeat(32),
|
||||
samplingRate: 0.5,
|
||||
excludePaths: [],
|
||||
redactQueryParams: [],
|
||||
redactHeaders: [],
|
||||
redactBodyFields: [],
|
||||
recordResponseSize: false,
|
||||
recordRequestBody: false,
|
||||
enableChainIntegrity: false,
|
||||
rng: mulberry32(1234567),
|
||||
};
|
||||
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config,
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
await app.request(`/x?i=${i}`);
|
||||
}
|
||||
|
||||
const count = storage.snapshot().length;
|
||||
expect(count).toBeGreaterThanOrEqual(400);
|
||||
expect(count).toBeLessThanOrEqual(600);
|
||||
});
|
||||
});
|
||||
14
packages/api-stack/api/api-plugin-audit/tests/setup/hono-shim.d.ts
vendored
Normal file
14
packages/api-stack/api/api-plugin-audit/tests/setup/hono-shim.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// Test-only Hono ContextVariableMap shim — adds the slots normally
|
||||
// contributed by sibling plugins (`@suaveplan/api-plugin-auth`,
|
||||
// `@suaveplan/api-tenant`, `@suaveplan/api`) so test files can call
|
||||
// `c.set("user", ...)`, `c.set("tenant", ...)`, `c.set("requestId", ...)`
|
||||
// without ts2769 errors when the sibling packages are not direct deps.
|
||||
import "hono";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
user: { id?: string } | null | undefined;
|
||||
tenant: { id?: string } | null | undefined;
|
||||
requestId: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-audit.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user