Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion src/core/RaidHubRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions src/core/RaidHubRoute.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* 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"
Expand Down Expand Up @@ -59,6 +60,7 @@
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<Params>,
any,
Expand All @@ -84,6 +86,8 @@
isAdministratorRoute?: boolean
isProtectedPlayerRoute?: boolean
isDeprecated?: boolean
/** Emits structured ADMIN_ACTION audit logs for accountability */
audit?: AuditRouteConfig
middleware?: RequestHandler<z.output<Params>, any, z.output<Body>, z.output<Query>>[]
handler: RaidHubHandler<
Params,
Expand Down Expand Up @@ -112,6 +116,7 @@
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
Expand Down Expand Up @@ -216,6 +221,7 @@
this.validateParams,
this.validateQuery,
this.validateBody,
...(this.auditConfig ? [auditRoute(this.auditConfig)] : []),
...this.middlewares,
async (req, res, next) => {
try {
Expand Down Expand Up @@ -275,7 +281,7 @@
this.parent = parent
}

getParent(): RaidHubRouter | null {

Check warning on line 284 in src/core/RaidHubRoute.ts

View workflow job for this annotation

GitHub Actions / test

284 line is not covered with tests
return this.parent
}

Expand All @@ -293,6 +299,7 @@
body: this.bodySchema,
isAdministratorRoute: this.isAdministratorRoute,
isProtectedPlayerRoute: this.isProtectedPlayerRoute,
audit: this.auditConfig ?? undefined,
middleware: this.middlewares,
handler: this.handler,
response: {
Expand Down
67 changes: 67 additions & 0 deletions src/lib/audit/audit-log.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
33 changes: 33 additions & 0 deletions src/lib/audit/audit-log.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
request?: Record<string, unknown>
response?: Record<string, unknown>
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 } : {})
})
}
71 changes: 71 additions & 0 deletions src/lib/audit/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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<string, unknown> = { value: "leaf" }
for (let i = 0; i < 10; i++) {
nested = { nested }
}

const sanitized = sanitizeForAudit(nested) as Record<string, unknown>
let current: unknown = sanitized
for (let i = 0; i < 8; i++) {
current = (current as Record<string, unknown>).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)")
})
})
Loading
Loading