From a2546425abfec36b15bd9a58cfb7f507cd05cfa3 Mon Sep 17 00:00:00 2001 From: Graaytech Date: Tue, 28 Jul 2026 08:39:26 -0700 Subject: [PATCH 1/2] Updated files Changes made --- .../__snapshots__/rate-limit.test.ts.snap | 61 +++++++ tests/schema/rate-limit.test.ts | 168 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/schema/__snapshots__/rate-limit.test.ts.snap create mode 100644 tests/schema/rate-limit.test.ts diff --git a/tests/schema/__snapshots__/rate-limit.test.ts.snap b/tests/schema/__snapshots__/rate-limit.test.ts.snap new file mode 100644 index 00000000..a3a8385c --- /dev/null +++ b/tests/schema/__snapshots__/rate-limit.test.ts.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GET /api/rate-limit — admin listing snapshot should maintain a stable forbidden error shape when unauthenticated 1`] = ` +{ + "error": { + "code": "forbidden", + }, +} +`; + +exports[`GET /api/rate-limit — admin listing snapshot should maintain a stable response shape for a successful paginated response 1`] = ` +{ + "data": [ + { + "action": "rate_limit.blocked", + "correlationId": "corr-1", + "createdAt": "2026-07-28T10:00:00.000Z", + "id": "log-1", + "ip": "192.168.1.1", + "rateLimitContext": null, + "walletAddress": "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + }, + ], + "nextCursor": "eyJzb3J0VmFsdWUiOiIyMDI2LTA3LTI4VDEwOjAwOjAwLjAwMFoifQ==", +} +`; + +exports[`GET /api/rate-limit — admin listing snapshot should maintain a stable validation error shape 1`] = ` +{ + "error": { + "code": "validation_error", + "message": "limit must be a positive integer", + "requestId": "test-req-id", + }, +} +`; + +exports[`GET /api/rate-limit/status — public status snapshot should maintain a stable anonymous status shape with zero usage 1`] = ` +{ + "data": { + "clientIp": "127.0.0.1", + "limit": 60, + "remaining": 60, + "resetAt": Any, + "type": "anonymous", + "used": 0, + "windowMs": 60000, + }, +} +`; + +exports[`GET /api/rate-limit/status — public status snapshot should maintain a stable authenticated status shape 1`] = ` +{ + "data": { + "bypasses": true, + "limit": 60, + "type": "authenticated", + "windowMs": 60000, + }, +} +`; diff --git a/tests/schema/rate-limit.test.ts b/tests/schema/rate-limit.test.ts new file mode 100644 index 00000000..4db8f209 --- /dev/null +++ b/tests/schema/rate-limit.test.ts @@ -0,0 +1,168 @@ +import express from "express"; +import request from "supertest"; +import { rateLimitRouter } from "../../src/routes/rate-limit"; +import { requestContextStorage } from "../../src/lib/requestContext"; +import { anonRateLimitStore, createRateLimitAnon } from "../../src/middleware/rateLimitAnon"; + +jest.mock("../../src/middleware/requireAdmin", () => ({ + requireAdmin: jest.fn((_req: any, _res: any, next: any) => { + next(); + }), +})); + +jest.mock("../../src/repositories/auditLogRepo"); + +jest.mock("../../src/config/logger", () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock("../../src/metrics/registry", () => { + const actual = jest.requireActual("../../src/metrics/registry"); + return { + ...actual, + rateLimitRequestDuration: { + observe: jest.fn(), + }, + }; +}); + +import { getAuditLogs } from "../../src/repositories/auditLogRepo"; + +const mockGetAuditLogs = getAuditLogs as jest.MockedFunction; + +function makeApp(): express.Express { + const app = express(); + app.use(express.json()); + app.use((_req, _res, next) => { + requestContextStorage.run({ requestId: "test-req-id" }, next); + }); + app.use("/api/rate-limit", rateLimitRouter); + return app; +} + +const app = makeApp(); + +beforeEach(() => { + jest.clearAllMocks(); + anonRateLimitStore.clear(); +}); + +describe("GET /api/rate-limit — admin listing snapshot", () => { + it("should maintain a stable response shape for a successful paginated response", async () => { + mockGetAuditLogs.mockResolvedValueOnce({ + data: [ + { + id: "log-1", + action: "rate_limit.blocked", + walletAddress: "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + ip: "192.168.1.1", + correlationId: "corr-1", + rateLimitContext: null, + createdAt: new Date("2026-07-28T10:00:00.000Z"), + }, + ], + nextCursor: "eyJzb3J0VmFsdWUiOiIyMDI2LTA3LTI4VDEwOjAwOjAwLjAwMFoifQ==", + }); + + const response = await request(app) + .get("/api/rate-limit") + .set("Authorization", "Bearer valid-admin-token"); + + expect(response.status).toBe(200); + expect(response.body).toMatchSnapshot(); + }); + + it("should maintain a stable validation error shape", async () => { + const response = await request(app) + .get("/api/rate-limit") + .set("Authorization", "Bearer valid-admin-token") + .query({ limit: "not-a-number" }); + + expect(response.status).toBe(400); + expect(response.body).toMatchSnapshot(); + }); + + it("should maintain a stable forbidden error shape when unauthenticated", async () => { + const mockRequireAdmin = jest.requireMock("../../src/middleware/requireAdmin").requireAdmin as jest.Mock; + mockRequireAdmin.mockImplementationOnce((_req: any, res: any, _next: any) => { + res.status(403).json({ error: { code: "forbidden" } }); + }); + + const response = await request(app).get("/api/rate-limit"); + + expect(response.status).toBe(403); + expect(response.body).toMatchSnapshot(); + }); + + it("should have the expected top-level fields on success", async () => { + mockGetAuditLogs.mockResolvedValueOnce({ + data: [], + nextCursor: null, + }); + + const response = await request(app) + .get("/api/rate-limit") + .set("Authorization", "Bearer valid-admin-token"); + + expect(response.body).toHaveProperty("data"); + expect(response.body).toHaveProperty("nextCursor"); + expect(Array.isArray(response.body.data)).toBe(true); + }); + + it("should return an empty data array when no audit logs exist", async () => { + mockGetAuditLogs.mockResolvedValueOnce({ + data: [], + nextCursor: null, + }); + + const response = await request(app) + .get("/api/rate-limit") + .set("Authorization", "Bearer valid-admin-token"); + + expect(response.body.data).toHaveLength(0); + expect(response.body.nextCursor).toBeNull(); + }); +}); + +describe("GET /api/rate-limit/status — public status snapshot", () => { + it("should maintain a stable anonymous status shape with zero usage", async () => { + const response = await request(app).get("/api/rate-limit/status"); + + expect(response.status).toBe(200); + expect(response.body).toMatchSnapshot({ + data: { resetAt: expect.any(String) }, + }); + }); + + it("should maintain a stable authenticated status shape", async () => { + const response = await request(app) + .get("/api/rate-limit/status") + .set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.dGVzdA"); + + expect(response.status).toBe(200); + expect(response.body).toMatchSnapshot(); + }); + + it("should report correct usage after making requests through the real limiter", async () => { + const localApp = express(); + localApp.use((_req, _res, next) => { + requestContextStorage.run({ requestId: "test-req-id" }, next); + }); + localApp.use("/api/rate-limit", rateLimitRouter); + localApp.use( + createRateLimitAnon({ windowMs: 60_000, max: 60, store: anonRateLimitStore }), + ); + localApp.get("/api/markets", (_req, res) => { + res.json({ data: [] }); + }); + + await request(localApp).get("/api/markets"); + await request(localApp).get("/api/markets"); + + const response = await request(localApp).get("/api/rate-limit/status"); + + expect(response.status).toBe(200); + expect(response.body.data.used).toBe(2); + expect(response.body.data.remaining).toBe(58); + }); +}); From eb169964327de5d196332d1e551e4cbacb0cc9e0 Mon Sep 17 00:00:00 2001 From: Graaytech Date: Tue, 28 Jul 2026 08:59:14 -0700 Subject: [PATCH 2/2] Updated files Changes made --- src/config/env-schema.ts | 3 + src/middleware/cors.ts | 23 ++++ src/routes/audit.ts | 4 + tests/auditCors.test.ts | 245 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 tests/auditCors.test.ts diff --git a/src/config/env-schema.ts b/src/config/env-schema.ts index 702208a5..a7bb7136 100644 --- a/src/config/env-schema.ts +++ b/src/config/env-schema.ts @@ -50,6 +50,9 @@ const baseSchema = z.object({ // ── Markets CORS ───────────────────────────────────────── MARKETS_CORS_ALLOWED_ORIGINS: z.string().default(""), + // ── Audit CORS ─────────────────────────────────────────── + AUDIT_CORS_ALLOWED_ORIGINS: z.string().default(""), + // ── Geo-blocking ────────────────────────────────────────── GEO_BLOCKED_COUNTRIES: z.string().default("").transform((val) => val.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean), diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index fbb5b074..5106b87a 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -137,3 +137,26 @@ export function marketsCors(): ReturnType } export const enforceCors = marketsCors(); + +/** + * Pre-configured CORS middleware for the audit endpoint. + * Reads allowed origins from the `AUDIT_CORS_ALLOWED_ORIGINS` env variable. + * When the allowlist is empty, all cross-origin requests to /api/audit are denied. + */ +let auditCorsMiddleware: ReturnType | null = null; + +export function auditCors(): ReturnType { + if (!auditCorsMiddleware) { + const raw = env.AUDIT_CORS_ALLOWED_ORIGINS ?? ""; + const allowedOrigins = raw + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); + auditCorsMiddleware = createCorsAllowlistMiddleware({ + allowedOrigins, + allowCredentials: true, + maxAgeSeconds: 600, + }); + } + return auditCorsMiddleware; +} diff --git a/src/routes/audit.ts b/src/routes/audit.ts index 9e78e065..eadde93e 100644 --- a/src/routes/audit.ts +++ b/src/routes/audit.ts @@ -1,8 +1,12 @@ import { Router } from "express"; import { accessLog } from "../middleware/accessLog"; +import { auditCors } from "../middleware/cors"; export const auditRouter = Router(); +// Apply CORS allowlist middleware from env (deny by default; preflight cached) +auditRouter.use(auditCors()); + // Apply structured access log middleware to all routes in this router auditRouter.use(accessLog); diff --git a/tests/auditCors.test.ts b/tests/auditCors.test.ts new file mode 100644 index 00000000..6975e32b --- /dev/null +++ b/tests/auditCors.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach } from "@jest/globals"; +import request from "supertest"; +import express from "express"; +import { createCorsAllowlistMiddleware } from "../src/middleware/cors"; + +/** + * Lightweight error handler for CORS tests. + * Avoids importing the broken errorHandler.ts which contains a merge conflict. + */ +function testErrorHandler( + err: unknown, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, +): void { + const status = (err as { status?: number }).status ?? 500; + const code = (err as { code?: string }).code ?? "internal_error"; + res.status(status).json({ error: { code } }); +} + +/** + * Builds a test app with a cors-protected /api/audit route to verify + * the CORS allowlist behaviour. Uses createCorsAllowlistMiddleware directly + * to simulate what auditCors() does, without requiring the full router + * or DB connections. + */ +function makeApp(allowedOrigins: string[]): express.Express { + const app = express(); + app.use(express.json()); + + const corsMw = createCorsAllowlistMiddleware({ + allowedOrigins, + allowCredentials: true, + maxAgeSeconds: 600, + }); + + app.use("/api/audit", corsMw, (_req, res) => { + res.json({ events: [] }); + }); + + app.use(testErrorHandler); + return app; +} + +describe("Audit CORS allowlist enforcement", () => { + describe("deny-by-default (empty allowlist)", () => { + it("denies all origins when AUDIT_CORS_ALLOWED_ORIGINS is empty", async () => { + const app = makeApp([]); + + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://trusted.example.com"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("denies requests with no Origin header when allowlist is empty", async () => { + const app = makeApp([]); + + const res = await request(app).get("/api/audit"); + + expect(res.status).toBe(403); + }); + }); + + describe("origin validation", () => { + let app: express.Express; + + beforeEach(() => { + app = makeApp([ + "http://localhost:5173", + "https://app.predictify.dev", + ]); + }); + + it("allows requests from an allowed origin", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.status).toBe(200); + }); + + it("sets Access-Control-Allow-Origin header on allowed requests", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-origin"]).toBe( + "http://localhost:5173", + ); + }); + + it("sets Access-Control-Allow-Credentials on allowed requests", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + it("denies requests from an origin not in the allowlist", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("denies requests with no Origin header", async () => { + const res = await request(app).get("/api/audit"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("includes correlationId in the error envelope", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.body.error.correlationId).toBeDefined(); + }); + + it("allows requests from the second configured origin", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://app.predictify.dev"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe( + "https://app.predictify.dev", + ); + }); + }); + + describe("preflight handling", () => { + let app: express.Express; + + beforeEach(() => { + app = makeApp([ + "http://localhost:5173", + "https://app.predictify.dev", + ]); + }); + + it("responds with 204 for allowed OPTIONS preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.status).toBe(204); + }); + + it("sets Access-Control-Max-Age on preflight response", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-max-age"]).toBe("600"); + }); + + it("sets Access-Control-Allow-Methods on preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-methods"]).toBeDefined(); + }); + + it("sets Access-Control-Allow-Headers on preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-headers"]).toContain( + "Content-Type", + ); + expect(res.headers["access-control-allow-headers"]).toContain( + "Authorization", + ); + }); + + it("denies preflight from disallowed origin", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + }); + }); + + describe("nested routes enforcement", () => { + it("enforces CORS on sub-paths like /api/audit/events", async () => { + const app = express(); + app.use(express.json()); + + const corsMw = createCorsAllowlistMiddleware({ + allowedOrigins: ["http://localhost:5173"], + allowCredentials: true, + }); + + // Simulate nested router structure: CORS applied on the parent + // and sub-routers inherit it. + const subRouter = express.Router(); + subRouter.get("/events", (_req, res) => res.json({ data: [] })); + + app.use("/api/audit", corsMw, subRouter); + app.use(testErrorHandler); + + const res = await request(app) + .get("/api/audit/events") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + }); + + it("allows allowed origins on sub-paths", async () => { + const app = express(); + app.use(express.json()); + + const corsMw = createCorsAllowlistMiddleware({ + allowedOrigins: ["http://localhost:5173"], + allowCredentials: true, + }); + + const subRouter = express.Router(); + subRouter.get("/:id", (_req, res) => res.json({ data: {} })); + + app.use("/api/audit", corsMw, subRouter); + app.use(testErrorHandler); + + const res = await request(app) + .get("/api/audit/some-audit-id") + .set("Origin", "http://localhost:5173"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe( + "http://localhost:5173", + ); + }); + }); +}); \ No newline at end of file