From 7f117222e615406dc22bee30f5f8e71cef635391 Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 14 Jun 2026 22:41:31 -0400 Subject: [PATCH 1/4] Add structured audit logging for admin mutation routes. Emit ADMIN_ACTION logs with actor, request, and outcome so manual blacklist and other privileged API changes are traceable in production logs. Co-authored-by: Cursor --- src/core/RaidHubRoute.ts | 7 ++ src/lib/audit/audit-log.ts | 33 +++++++ src/lib/audit/sanitize.ts | 74 +++++++++++++++ src/middleware/audit-log.test.ts | 115 ++++++++++++++++++++++++ src/middleware/audit-log.ts | 114 +++++++++++++++++++++++ src/middleware/types.ts | 2 + src/routes/admin/query.ts | 4 + src/routes/admin/reporting/blacklist.ts | 4 + src/routes/admin/reporting/player.ts | 3 + 9 files changed, 356 insertions(+) create mode 100644 src/lib/audit/audit-log.ts create mode 100644 src/lib/audit/sanitize.ts create mode 100644 src/middleware/audit-log.test.ts create mode 100644 src/middleware/audit-log.ts diff --git a/src/core/RaidHubRoute.ts b/src/core/RaidHubRoute.ts index 25b05111..9560e401 100644 --- a/src/core/RaidHubRoute.ts +++ b/src/core/RaidHubRoute.ts @@ -4,6 +4,7 @@ import { Logger } from "@/lib/utils/logging" import { durationMetrics } from "@/middleware/duration-metrics" import { regionMetrics } from "@/middleware/region-metrics" import { requestLogging } from "@/middleware/request-logging" +import { AuditRouteConfig, auditRoute } from "@/middleware/audit-log" import { zApiKeyError } from "@/schema/errors/ApiKeyError" import { BodyValidationError, zBodyValidationError } from "@/schema/errors/BodyValidationError" import { ErrorCode } from "@/schema/errors/ErrorCode" @@ -59,6 +60,7 @@ export class RaidHubRoute< private readonly isAdministratorRoute: boolean = false private readonly isProtectedPlayerRoute: boolean = false private readonly isDeprecated: boolean = false + private readonly auditConfig: AuditRouteConfig | null = null private readonly middlewares: RequestHandler< z.output, any, @@ -84,6 +86,8 @@ export class RaidHubRoute< isAdministratorRoute?: boolean isProtectedPlayerRoute?: boolean isDeprecated?: boolean + /** Emits structured ADMIN_ACTION audit logs for accountability */ + audit?: AuditRouteConfig middleware?: RequestHandler, any, z.output, z.output>[] handler: RaidHubHandler< Params, @@ -112,6 +116,7 @@ export class RaidHubRoute< this.isAdministratorRoute = args.isAdministratorRoute ?? false this.isProtectedPlayerRoute = args.isProtectedPlayerRoute ?? false this.isDeprecated = args.isDeprecated ?? false + this.auditConfig = args.audit ?? null this.middlewares = args.middleware ?? [] this.handler = args.handler this.responseSchema = args.response.success.schema @@ -216,6 +221,7 @@ export class RaidHubRoute< this.validateParams, this.validateQuery, this.validateBody, + ...(this.auditConfig ? [auditRoute(this.auditConfig)] : []), ...this.middlewares, async (req, res, next) => { try { @@ -293,6 +299,7 @@ export class RaidHubRoute< body: this.bodySchema, isAdministratorRoute: this.isAdministratorRoute, isProtectedPlayerRoute: this.isProtectedPlayerRoute, + audit: this.auditConfig ?? undefined, middleware: this.middlewares, handler: this.handler, response: { diff --git a/src/lib/audit/audit-log.ts b/src/lib/audit/audit-log.ts new file mode 100644 index 00000000..37cc0a1f --- /dev/null +++ b/src/lib/audit/audit-log.ts @@ -0,0 +1,33 @@ +import { Logger } from "@/lib/utils/logging" + +const logger = new Logger("AUDIT") + +export type AuditOutcome = "success" | "failure" + +export type AuditRecord = { + action: string + actorBungieMembershipId: string + method: string + route: string + outcome: AuditOutcome + statusCode: number + params?: Record + request?: Record + response?: Record + errorCode?: string +} + +export const writeAuditLog = (record: AuditRecord): void => { + logger.info("ADMIN_ACTION", { + action: record.action, + actor_bungie_membership_id: record.actorBungieMembershipId, + method: record.method, + route: record.route, + outcome: record.outcome, + status_code: record.statusCode, + ...(record.params ? { params: JSON.stringify(record.params) } : {}), + ...(record.request ? { request: JSON.stringify(record.request) } : {}), + ...(record.response ? { response: JSON.stringify(record.response) } : {}), + ...(record.errorCode ? { error_code: record.errorCode } : {}) + }) +} diff --git a/src/lib/audit/sanitize.ts b/src/lib/audit/sanitize.ts new file mode 100644 index 00000000..5ad39583 --- /dev/null +++ b/src/lib/audit/sanitize.ts @@ -0,0 +1,74 @@ +const DEFAULT_REDACT_KEYS = new Set([ + "password", + "token", + "secret", + "authorization", + "apikey", + "api_key" +]) + +const DEFAULT_MAX_STRING_LENGTH = 8_192 + +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export type SanitizeForAuditOptions = { + redactKeys?: readonly string[] + maxStringLength?: number +} + +export const sanitizeForAudit = ( + value: unknown, + options: SanitizeForAuditOptions = {} +): unknown => { + const redactKeys = new Set([ + ...DEFAULT_REDACT_KEYS, + ...(options.redactKeys ?? []).map(k => k.toLowerCase()) + ]) + const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH + + const sanitize = (current: unknown, depth: number): unknown => { + if (depth > 8) { + return "[Truncated: max depth]" + } + + if (current === null || current === undefined) { + return current + } + + if (typeof current === "string") { + if (current.length <= maxStringLength) { + return current + } + return `${current.slice(0, maxStringLength)}…[truncated ${current.length - maxStringLength} chars]` + } + + if (typeof current === "number" || typeof current === "boolean") { + return current + } + + if (typeof current === "bigint") { + return current.toString() + } + + if (Array.isArray(current)) { + return current.map(item => sanitize(item, depth + 1)) + } + + if (!isPlainObject(current)) { + return String(current) + } + + const sanitized: Record = {} + for (const [key, nested] of Object.entries(current)) { + if (redactKeys.has(key.toLowerCase())) { + sanitized[key] = "[REDACTED]" + continue + } + sanitized[key] = sanitize(nested, depth + 1) + } + return sanitized + } + + return sanitize(value, 0) +} diff --git a/src/middleware/audit-log.test.ts b/src/middleware/audit-log.test.ts new file mode 100644 index 00000000..5db7f46f --- /dev/null +++ b/src/middleware/audit-log.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { sanitizeForAudit } from "@/lib/audit/sanitize" +import * as auditLog from "@/lib/audit/audit-log" +import { generateJWT } from "@/auth/jwt" +import { attachUserAuth } from "@/auth/user-context" +import { adminProtected } from "@/auth/admin" +import express from "express" +import request from "supertest" +import { auditRoute } from "./audit-log" + +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret" + +describe("sanitizeForAudit", () => { + test("redacts sensitive keys", () => { + expect( + sanitizeForAudit({ + reason: "cheaters", + password: "hunter2", + nested: { apiKey: "secret-value" } + }) + ).toEqual({ + reason: "cheaters", + password: "[REDACTED]", + nested: { apiKey: "[REDACTED]" } + }) + }) + + test("truncates long strings", () => { + const long = "x".repeat(9000) + const sanitized = sanitizeForAudit(long, { maxStringLength: 100 }) as string + expect(sanitized.startsWith("x".repeat(100))).toBe(true) + expect(sanitized).toContain("[truncated") + }) +}) + +describe("auditRoute middleware", () => { + const adminToken = () => + generateJWT( + { + isAdmin: true, + bungieMembershipId: "4611686018555780000", + destinyMembershipIds: [] + }, + 600 + ) + + test("logs successful admin mutation with actor, params, and request body", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = express() + app.use(express.json()) + app.use(attachUserAuth) + app.use(adminProtected) + app.put( + "/admin/reporting/blacklist/:instanceId", + auditRoute({ + action: "reporting.blacklist.update", + responseFields: ["blacklisted"] + }), + (req, res) => { + res.status(200).json({ + minted: new Date(), + success: true, + response: { blacklisted: true } + }) + } + ) + + const res = await request(app) + .put("/admin/reporting/blacklist/16897747714") + .set("Authorization", "Bearer " + adminToken()) + .send({ + reason: "2 man", + removeBlacklist: false + }) + + expect(res.status).toBe(200) + expect(auditSpy).toHaveBeenCalledTimes(1) + + const record = auditSpy.mock.calls[0][0] + expect(record.action).toBe("reporting.blacklist.update") + expect(record.actorBungieMembershipId).toBe("4611686018555780000") + expect(record.outcome).toBe("success") + expect(record.statusCode).toBe(200) + expect(record.params?.instanceId).toBe("16897747714") + expect(record.request?.reason).toBe("2 man") + expect(record.response?.blacklisted).toBe(true) + } finally { + auditSpy.mockRestore() + } + }) + + test("does not log when actor is missing", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = express() + app.use(express.json()) + app.post( + "/admin/query", + auditRoute({ action: "admin.query.execute" }), + (_req, res) => { + res.status(200).json({ success: true }) + } + ) + + await request(app).post("/admin/query").send({ query: "SELECT 1" }) + + expect(auditSpy).not.toHaveBeenCalled() + } finally { + auditSpy.mockRestore() + } + }) +}) diff --git a/src/middleware/audit-log.ts b/src/middleware/audit-log.ts new file mode 100644 index 00000000..6a474aed --- /dev/null +++ b/src/middleware/audit-log.ts @@ -0,0 +1,114 @@ +import { writeAuditLog, type AuditOutcome } from "@/lib/audit/audit-log" +import { sanitizeForAudit } from "@/lib/audit/sanitize" +import { RequestHandler } from "express" +import type { ParamsDictionary, Query } from "express-serve-static-core" +import { RaidHubLocals } from "./types" + +export type AuditRouteConfig = { + /** Stable identifier, e.g. reporting.blacklist.update */ + action: string + includeParams?: boolean + includeBody?: boolean + redactBodyKeys?: string[] + /** Response JSON keys to include when present (e.g. blacklisted) */ + responseFields?: string[] +} + +const pickResponseFields = ( + body: unknown, + fields: string[] | undefined +): Record | undefined => { + if (typeof body !== "object" || body === null) { + return undefined + } + + const source = body as Record + const payload = + source.response && typeof source.response === "object" + ? (source.response as Record) + : source + + const picked: Record = {} + + if (fields?.length) { + for (const field of fields) { + if (field in payload) { + picked[field] = payload[field] + } + } + } + + if ("success" in source) { + picked.success = source.success + } + if (source.success === false && typeof source.code === "string") { + picked.code = source.code + } + + return Object.keys(picked).length > 0 ? picked : undefined +} + +export const auditRoute = < + P extends ParamsDictionary = ParamsDictionary, + ResBody = unknown, + ReqBody = unknown, + ReqQuery extends Query = Query +>( + config: AuditRouteConfig +): RequestHandler => { + const includeParams = config.includeParams ?? true + const includeBody = config.includeBody ?? true + + return (req, res, next) => { + const originalJson = res.json.bind(res) + + res.json = ((body?: ResBody) => { + res.locals._auditResponseBody = body + return originalJson(body as ResBody) + }) as typeof res.json + + res.once("finish", () => { + const actor = req.auth?.bungieMembershipId + if (!actor) { + return + } + + const statusCode = res.statusCode + const outcome: AuditOutcome = statusCode >= 400 ? "failure" : "success" + const responseBody = res.locals._auditResponseBody + + let errorCode: string | undefined + if (typeof responseBody === "object" && responseBody !== null && "success" in responseBody) { + const failedResponse = responseBody as { success?: boolean; code?: unknown } + if (failedResponse.success === false && typeof failedResponse.code === "string") { + errorCode = failedResponse.code + } + } + + writeAuditLog({ + action: config.action, + actorBungieMembershipId: actor, + method: req.method, + route: req.originalUrl.split("?")[0] ?? req.path, + outcome, + statusCode, + errorCode, + ...(includeParams + ? { + params: sanitizeForAudit(req.params) as Record + } + : {}), + ...(includeBody && req.body != null + ? { + request: sanitizeForAudit(req.body, { + redactKeys: config.redactBodyKeys + }) as Record + } + : {}), + response: pickResponseFields(responseBody, config.responseFields) + }) + }) + + next() + } +} diff --git a/src/middleware/types.ts b/src/middleware/types.ts index e2a63fe1..18cd6a92 100644 --- a/src/middleware/types.ts +++ b/src/middleware/types.ts @@ -6,4 +6,6 @@ export interface RaidHubLocals { // populated by duration-metrics _startTime: number _duration: number + // populated by audit-log middleware + _auditResponseBody?: unknown } diff --git a/src/routes/admin/query.ts b/src/routes/admin/query.ts index 5ee693d0..587af755 100644 --- a/src/routes/admin/query.ts +++ b/src/routes/admin/query.ts @@ -10,6 +10,10 @@ export const adminQueryRoute = new RaidHubRoute({ isAdministratorRoute: true, description: "Run a query against the database", method: "post", + audit: { + action: "admin.query.execute", + responseFields: ["type", "cost"] + }, body: z.object({ query: z.string(), type: z.enum(["SELECT", "EXPLAIN"]), diff --git a/src/routes/admin/reporting/blacklist.ts b/src/routes/admin/reporting/blacklist.ts index 1841aa4b..7271d764 100644 --- a/src/routes/admin/reporting/blacklist.ts +++ b/src/routes/admin/reporting/blacklist.ts @@ -10,6 +10,10 @@ export const blacklistInstanceRoute = new RaidHubRoute({ isAdministratorRoute: true, method: "put", description: "Blacklist an instance from leaderboards, as well as the players involved.", + audit: { + action: "reporting.blacklist.update", + responseFields: ["blacklisted"] + }, params: z.object({ instanceId: zBigIntString() }), diff --git a/src/routes/admin/reporting/player.ts b/src/routes/admin/reporting/player.ts index aaf40754..fd9c4134 100644 --- a/src/routes/admin/reporting/player.ts +++ b/src/routes/admin/reporting/player.ts @@ -11,6 +11,9 @@ export const patchPlayer = new RaidHubRoute({ isAdministratorRoute: true, method: "patch", description: "Update fields on a player. Currently, only the cheat level can be updated.", + audit: { + action: "reporting.player.update" + }, params: z.object({ membershipId: zBigIntString() }), From e5dd1383965b49aa0eeca3ca1bc3539b4602cd00 Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 14 Jun 2026 22:45:18 -0400 Subject: [PATCH 2/4] Fix Prettier import ordering on audit logging files. Co-authored-by: Cursor --- src/core/RaidHubRoute.ts | 2 +- src/middleware/audit-log.test.ts | 18 +++++++----------- src/middleware/audit-log.ts | 6 +++++- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/core/RaidHubRoute.ts b/src/core/RaidHubRoute.ts index 9560e401..d5ff9bd6 100644 --- a/src/core/RaidHubRoute.ts +++ b/src/core/RaidHubRoute.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */ import { authFromHeaders } from "@/auth/user-context" import { Logger } from "@/lib/utils/logging" +import { AuditRouteConfig, auditRoute } from "@/middleware/audit-log" import { durationMetrics } from "@/middleware/duration-metrics" import { regionMetrics } from "@/middleware/region-metrics" import { requestLogging } from "@/middleware/request-logging" -import { AuditRouteConfig, auditRoute } from "@/middleware/audit-log" import { zApiKeyError } from "@/schema/errors/ApiKeyError" import { BodyValidationError, zBodyValidationError } from "@/schema/errors/BodyValidationError" import { ErrorCode } from "@/schema/errors/ErrorCode" diff --git a/src/middleware/audit-log.test.ts b/src/middleware/audit-log.test.ts index 5db7f46f..1ea10d98 100644 --- a/src/middleware/audit-log.test.ts +++ b/src/middleware/audit-log.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, spyOn, test } from "bun:test" -import { sanitizeForAudit } from "@/lib/audit/sanitize" -import * as auditLog from "@/lib/audit/audit-log" +import { adminProtected } from "@/auth/admin" import { generateJWT } from "@/auth/jwt" import { attachUserAuth } from "@/auth/user-context" -import { adminProtected } from "@/auth/admin" +import * as auditLog from "@/lib/audit/audit-log" +import { sanitizeForAudit } from "@/lib/audit/sanitize" +import { describe, expect, spyOn, test } from "bun:test" import express from "express" import request from "supertest" import { auditRoute } from "./audit-log" @@ -97,13 +97,9 @@ describe("auditRoute middleware", () => { try { const app = express() app.use(express.json()) - app.post( - "/admin/query", - auditRoute({ action: "admin.query.execute" }), - (_req, res) => { - res.status(200).json({ success: true }) - } - ) + app.post("/admin/query", auditRoute({ action: "admin.query.execute" }), (_req, res) => { + res.status(200).json({ success: true }) + }) await request(app).post("/admin/query").send({ query: "SELECT 1" }) diff --git a/src/middleware/audit-log.ts b/src/middleware/audit-log.ts index 6a474aed..2627837b 100644 --- a/src/middleware/audit-log.ts +++ b/src/middleware/audit-log.ts @@ -78,7 +78,11 @@ export const auditRoute = < const responseBody = res.locals._auditResponseBody let errorCode: string | undefined - if (typeof responseBody === "object" && responseBody !== null && "success" in responseBody) { + if ( + typeof responseBody === "object" && + responseBody !== null && + "success" in responseBody + ) { const failedResponse = responseBody as { success?: boolean; code?: unknown } if (failedResponse.success === false && typeof failedResponse.code === "string") { errorCode = failedResponse.code From 5ac00930087fff0be23ada185583ea322b8918b5 Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 14 Jun 2026 22:53:30 -0400 Subject: [PATCH 3/4] Expand audit logging test coverage for Barecheck. Cover writeAuditLog, sanitizeForAudit edge cases, middleware failure paths, and RaidHubRoute audit wiring. Co-authored-by: Cursor --- src/core/RaidHubRoute.test.ts | 106 ++++++++++++++- src/lib/audit/audit-log.test.ts | 67 ++++++++++ src/lib/audit/sanitize.test.ts | 78 +++++++++++ src/middleware/audit-log.test.ts | 217 ++++++++++++++++++++++++++----- 4 files changed, 436 insertions(+), 32 deletions(-) create mode 100644 src/lib/audit/audit-log.test.ts create mode 100644 src/lib/audit/sanitize.test.ts diff --git a/src/core/RaidHubRoute.test.ts b/src/core/RaidHubRoute.test.ts index 1f5289c2..5004619d 100644 --- a/src/core/RaidHubRoute.test.ts +++ b/src/core/RaidHubRoute.test.ts @@ -1,5 +1,9 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test" +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { adminProtected } from "@/auth/admin" +import { generateJWT } from "@/auth/jwt" +import { attachUserAuth } from "@/auth/user-context" +import * as auditLog from "@/lib/audit/audit-log" import { errorHandler } from "@/middleware/error-handler" import { ErrorCode } from "@/schema/errors/ErrorCode" import { zBigIntString, zDigitString } from "@/schema/input" @@ -269,6 +273,106 @@ describe("raidhub route unhandled error", () => { }) }) +describe("raidhub route audit logging", () => { + test("mountable emits audit log when route has audit config", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const auditRoute = new RaidHubRoute({ + method: "post", + description: "audit test route", + isAdministratorRoute: true, + audit: { + action: "test.audit.action", + responseFields: ["ok"] + }, + handler: async () => + RaidHubRoute.ok({ + ok: true + }), + response: { + success: { + statusCode: 200, + schema: z.object({ + ok: z.boolean() + }) + } + } + }) + + const auditApp = express() + auditApp.use(express.json()) + auditApp.use(attachUserAuth) + auditApp.use(adminProtected) + auditApp.use("/audit-test", auditRoute.mountable) + + const token = generateJWT( + { + isAdmin: true, + bungieMembershipId: "4611686018555780000", + destinyMembershipIds: [] + }, + 600 + ) + + const res = await request(auditApp) + .post("/audit-test") + .set("Authorization", "Bearer " + token) + + expect(res.status).toBe(200) + expect(auditSpy).toHaveBeenCalledTimes(1) + expect(auditSpy.mock.calls[0][0].action).toBe("test.audit.action") + } finally { + auditSpy.mockRestore() + } + }) + + test("deprecatedCopy preserves audit config", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const route = new RaidHubRoute({ + method: "put", + description: "audit copy test", + isAdministratorRoute: true, + audit: { action: "test.audit.copy", responseFields: ["copied"] }, + handler: async () => RaidHubRoute.ok({ copied: true }), + response: { + success: { + statusCode: 200, + schema: z.object({ copied: z.boolean() }) + } + } + }) + + const auditApp = express() + auditApp.use(express.json()) + auditApp.use(attachUserAuth) + auditApp.use(adminProtected) + auditApp.use("/audit-copy", route.deprecatedCopy().mountable) + + const token = generateJWT( + { + isAdmin: true, + bungieMembershipId: "4611686018555780000", + destinyMembershipIds: [] + }, + 600 + ) + + await request(auditApp) + .put("/audit-copy") + .set("Authorization", "Bearer " + token) + + expect(auditSpy).toHaveBeenCalledTimes(1) + expect(auditSpy.mock.calls[0][0].action).toBe("test.audit.copy") + expect(auditSpy.mock.calls[0][0].response?.copied).toBe(true) + } finally { + auditSpy.mockRestore() + } + }) +}) + describe("test raidhub route openapi gen", () => { test("get schema", () => { const openapi = testGetRoute.$generateOpenApiRoutes()[0] diff --git a/src/lib/audit/audit-log.test.ts b/src/lib/audit/audit-log.test.ts new file mode 100644 index 00000000..d2b2780f --- /dev/null +++ b/src/lib/audit/audit-log.test.ts @@ -0,0 +1,67 @@ +import { Logger } from "@/lib/utils/logging" +import { describe, expect, spyOn, test } from "bun:test" + +import { writeAuditLog } from "./audit-log" + +describe("writeAuditLog", () => { + test("forwards full record to logger with stringified optional fields", () => { + const infoSpy = spyOn(Logger.prototype, "info").mockImplementation(() => {}) + + try { + writeAuditLog({ + action: "reporting.blacklist.update", + actorBungieMembershipId: "4611686018555780000", + method: "PUT", + route: "/admin/reporting/blacklist/123", + outcome: "success", + statusCode: 200, + params: { instanceId: "123" }, + request: { reason: "2 man" }, + response: { blacklisted: true }, + errorCode: "PlayerNotFoundError" + }) + + expect(infoSpy).toHaveBeenCalledTimes(1) + expect(infoSpy).toHaveBeenCalledWith("ADMIN_ACTION", { + action: "reporting.blacklist.update", + actor_bungie_membership_id: "4611686018555780000", + method: "PUT", + route: "/admin/reporting/blacklist/123", + outcome: "success", + status_code: 200, + params: JSON.stringify({ instanceId: "123" }), + request: JSON.stringify({ reason: "2 man" }), + response: JSON.stringify({ blacklisted: true }), + error_code: "PlayerNotFoundError" + }) + } finally { + infoSpy.mockRestore() + } + }) + + test("omits optional fields when absent", () => { + const infoSpy = spyOn(Logger.prototype, "info").mockImplementation(() => {}) + + try { + writeAuditLog({ + action: "admin.query.execute", + actorBungieMembershipId: "4611686018555780000", + method: "POST", + route: "/admin/query", + outcome: "failure", + statusCode: 403 + }) + + expect(infoSpy).toHaveBeenCalledWith("ADMIN_ACTION", { + action: "admin.query.execute", + actor_bungie_membership_id: "4611686018555780000", + method: "POST", + route: "/admin/query", + outcome: "failure", + status_code: 403 + }) + } finally { + infoSpy.mockRestore() + } + }) +}) diff --git a/src/lib/audit/sanitize.test.ts b/src/lib/audit/sanitize.test.ts new file mode 100644 index 00000000..56a49d48 --- /dev/null +++ b/src/lib/audit/sanitize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" + +import { sanitizeForAudit } from "./sanitize" + +describe("sanitizeForAudit", () => { + test("redacts sensitive keys", () => { + expect( + sanitizeForAudit({ + reason: "cheaters", + password: "hunter2", + nested: { apiKey: "secret-value" } + }) + ).toEqual({ + reason: "cheaters", + password: "[REDACTED]", + nested: { apiKey: "[REDACTED]" } + }) + }) + + test("truncates long strings", () => { + const long = "x".repeat(9000) + const sanitized = sanitizeForAudit(long, { maxStringLength: 100 }) as string + expect(sanitized.startsWith("x".repeat(100))).toBe(true) + expect(sanitized).toContain("[truncated") + }) + + test("passes through null and undefined", () => { + expect(sanitizeForAudit(null)).toBe(null) + expect(sanitizeForAudit(undefined)).toBe(undefined) + }) + + test("passes through numbers and booleans", () => { + expect(sanitizeForAudit(42)).toBe(42) + expect(sanitizeForAudit(false)).toBe(false) + }) + + test("stringifies bigint values", () => { + expect(sanitizeForAudit(16897747714n)).toBe("16897747714") + }) + + test("sanitizes arrays recursively", () => { + expect( + sanitizeForAudit([ + { token: "secret" }, + "visible", + [1, { api_key: "hidden" }] + ]) + ).toEqual([{ token: "[REDACTED]" }, "visible", [1, { api_key: "[REDACTED]" }]]) + }) + + test("redacts custom keys", () => { + expect( + sanitizeForAudit( + { query: "SELECT 1", note: "safe" }, + { redactKeys: ["query"] } + ) + ).toEqual({ query: "[REDACTED]", note: "safe" }) + }) + + test("truncates at max depth", () => { + let nested: Record = { value: "leaf" } + for (let i = 0; i < 10; i++) { + nested = { nested } + } + + const sanitized = sanitizeForAudit(nested) as Record + let current: unknown = sanitized + for (let i = 0; i < 8; i++) { + current = (current as Record).nested + } + expect(current).toEqual({ nested: "[Truncated: max depth]" }) + }) + + test("stringifies non-plain values", () => { + expect(sanitizeForAudit(() => "noop")).toBe('() => "noop"') + expect(sanitizeForAudit(Symbol("tag"))).toBe("Symbol(tag)") + }) +}) diff --git a/src/middleware/audit-log.test.ts b/src/middleware/audit-log.test.ts index 1ea10d98..96787eaa 100644 --- a/src/middleware/audit-log.test.ts +++ b/src/middleware/audit-log.test.ts @@ -2,37 +2,14 @@ import { adminProtected } from "@/auth/admin" import { generateJWT } from "@/auth/jwt" import { attachUserAuth } from "@/auth/user-context" import * as auditLog from "@/lib/audit/audit-log" -import { sanitizeForAudit } from "@/lib/audit/sanitize" import { describe, expect, spyOn, test } from "bun:test" import express from "express" import request from "supertest" + import { auditRoute } from "./audit-log" process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret" -describe("sanitizeForAudit", () => { - test("redacts sensitive keys", () => { - expect( - sanitizeForAudit({ - reason: "cheaters", - password: "hunter2", - nested: { apiKey: "secret-value" } - }) - ).toEqual({ - reason: "cheaters", - password: "[REDACTED]", - nested: { apiKey: "[REDACTED]" } - }) - }) - - test("truncates long strings", () => { - const long = "x".repeat(9000) - const sanitized = sanitizeForAudit(long, { maxStringLength: 100 }) as string - expect(sanitized.startsWith("x".repeat(100))).toBe(true) - expect(sanitized).toContain("[truncated") - }) -}) - describe("auditRoute middleware", () => { const adminToken = () => generateJWT( @@ -44,20 +21,29 @@ describe("auditRoute middleware", () => { 600 ) + const buildAuthedApp = ( + path: string, + config: Parameters[0], + handler: express.RequestHandler + ) => { + const app = express() + app.use(express.json()) + app.use(attachUserAuth) + app.use(adminProtected) + app.use(path, auditRoute(config), handler) + return app + } + test("logs successful admin mutation with actor, params, and request body", async () => { const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) try { - const app = express() - app.use(express.json()) - app.use(attachUserAuth) - app.use(adminProtected) - app.put( + const app = buildAuthedApp( "/admin/reporting/blacklist/:instanceId", - auditRoute({ + { action: "reporting.blacklist.update", responseFields: ["blacklisted"] - }), + }, (req, res) => { res.status(200).json({ minted: new Date(), @@ -86,6 +72,175 @@ describe("auditRoute middleware", () => { expect(record.params?.instanceId).toBe("16897747714") expect(record.request?.reason).toBe("2 man") expect(record.response?.blacklisted).toBe(true) + expect(record.errorCode).toBeUndefined() + } finally { + auditSpy.mockRestore() + } + }) + + test("logs failure outcome and error code", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/reporting/player/:membershipId", + { + action: "reporting.player.update", + responseFields: ["updated"] + }, + (_req, res) => { + res.status(404).json({ + minted: new Date(), + success: false, + code: "PlayerNotFoundError", + error: { playerId: "999" } + }) + } + ) + + await request(app) + .patch("/admin/reporting/player/999") + .set("Authorization", "Bearer " + adminToken()) + .send({ flagged: true }) + + expect(auditSpy).toHaveBeenCalledTimes(1) + const record = auditSpy.mock.calls[0][0] + expect(record.outcome).toBe("failure") + expect(record.statusCode).toBe(404) + expect(record.errorCode).toBe("PlayerNotFoundError") + expect(record.response?.success).toBe(false) + expect(record.response?.code).toBe("PlayerNotFoundError") + } finally { + auditSpy.mockRestore() + } + }) + + test("strips query string from route", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/query", + { action: "admin.query.execute" }, + (_req, res) => { + res.status(200).json({ success: true, response: { rows: [] } }) + } + ) + + await request(app) + .post("/admin/query?debug=1") + .set("Authorization", "Bearer " + adminToken()) + .send({ query: "SELECT 1", type: "readonly" }) + + const record = auditSpy.mock.calls[0][0] + expect(record.route).toBe("/admin/query") + } finally { + auditSpy.mockRestore() + } + }) + + test("respects includeParams and includeBody flags", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/reporting/blacklist/:instanceId", + { + action: "reporting.blacklist.update", + includeParams: false, + includeBody: false + }, + (_req, res) => { + res.status(200).json({ success: true }) + } + ) + + await request(app) + .put("/admin/reporting/blacklist/16897747714") + .set("Authorization", "Bearer " + adminToken()) + .send({ reason: "secret reason" }) + + const record = auditSpy.mock.calls[0][0] + expect(record.params).toBeUndefined() + expect(record.request).toBeUndefined() + } finally { + auditSpy.mockRestore() + } + }) + + test("applies custom redactBodyKeys", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/query", + { + action: "admin.query.execute", + redactBodyKeys: ["query"] + }, + (_req, res) => { + res.status(200).json({ success: true }) + } + ) + + await request(app) + .post("/admin/query") + .set("Authorization", "Bearer " + adminToken()) + .send({ query: "SELECT * FROM player", type: "readonly" }) + + const record = auditSpy.mock.calls[0][0] + expect(record.request?.query).toBe("[REDACTED]") + expect(record.request?.type).toBe("readonly") + } finally { + auditSpy.mockRestore() + } + }) + + test("picks response fields from unwrapped body", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/custom", + { + action: "custom.action", + responseFields: ["value"] + }, + (_req, res) => { + res.status(200).json({ value: 42, extra: "ignored" }) + } + ) + + await request(app) + .post("/admin/custom") + .set("Authorization", "Bearer " + adminToken()) + .send({}) + + const record = auditSpy.mock.calls[0][0] + expect(record.response).toEqual({ value: 42 }) + } finally { + auditSpy.mockRestore() + } + }) + + test("omits response when body is not an object", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/custom", + { action: "custom.action", responseFields: ["value"] }, + (_req, res) => { + res.status(204).end() + } + ) + + await request(app) + .post("/admin/custom") + .set("Authorization", "Bearer " + adminToken()) + + const record = auditSpy.mock.calls[0][0] + expect(record.response).toBeUndefined() } finally { auditSpy.mockRestore() } From 7c93d0d2c6a278bace9888ea55319e2481d7a90f Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 14 Jun 2026 23:02:29 -0400 Subject: [PATCH 4/4] Fix Prettier formatting in sanitize.test.ts. Co-authored-by: Cursor --- src/lib/audit/sanitize.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/lib/audit/sanitize.test.ts b/src/lib/audit/sanitize.test.ts index 56a49d48..5d190268 100644 --- a/src/lib/audit/sanitize.test.ts +++ b/src/lib/audit/sanitize.test.ts @@ -40,20 +40,13 @@ describe("sanitizeForAudit", () => { test("sanitizes arrays recursively", () => { expect( - sanitizeForAudit([ - { token: "secret" }, - "visible", - [1, { api_key: "hidden" }] - ]) + sanitizeForAudit([{ token: "secret" }, "visible", [1, { api_key: "hidden" }]]) ).toEqual([{ token: "[REDACTED]" }, "visible", [1, { api_key: "[REDACTED]" }]]) }) test("redacts custom keys", () => { expect( - sanitizeForAudit( - { query: "SELECT 1", note: "safe" }, - { redactKeys: ["query"] } - ) + sanitizeForAudit({ query: "SELECT 1", note: "safe" }, { redactKeys: ["query"] }) ).toEqual({ query: "[REDACTED]", note: "safe" }) })