diff --git a/migrations/1776000000006_add-interest-rate-to-loan-events.js b/migrations/1776000000006_add-interest-rate-to-loan-events.js index 1155466..aafbbf9 100644 --- a/migrations/1776000000006_add-interest-rate-to-loan-events.js +++ b/migrations/1776000000006_add-interest-rate-to-loan-events.js @@ -1,4 +1,4 @@ -exports.up = (pgm) => { +export const up = (pgm) => { pgm.addColumns("loan_events", { interest_rate_bps: { type: "integer", default: null }, term_ledgers: { type: "integer", default: null }, @@ -8,6 +8,6 @@ exports.up = (pgm) => { // but for now we'll just track the rate per-loan event. }; -exports.down = (pgm) => { +export const down = (pgm) => { pgm.dropColumns("loan_events", ["interest_rate_bps", "term_ledgers"]); }; diff --git a/src/__tests__/adminReindex.test.ts b/src/__tests__/adminReindex.test.ts index b95309b..b386748 100644 --- a/src/__tests__/adminReindex.test.ts +++ b/src/__tests__/adminReindex.test.ts @@ -16,7 +16,7 @@ describe("Admin reindex endpoint", () => { expect(response.status).toBe(401); }); - it("validates ledger range query parameters", async () => { + it("validates ledger range query parameters - invalid fromLedger", async () => { const response = await request(app) .post("/api/admin/reindex?fromLedger=abc&toLedger=2") .set("x-api-key", apiKey); @@ -25,13 +25,40 @@ describe("Admin reindex endpoint", () => { expect(response.body.success).toBe(false); }); + it("validates ledger range query parameters - invalid toLedger", async () => { + const response = await request(app) + .post("/api/admin/reindex?fromLedger=1&toLedger=xyz") + .set("x-api-key", apiKey); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates ledger range query parameters - negative fromLedger", async () => { + const response = await request(app) + .post("/api/admin/reindex?fromLedger=-1&toLedger=2") + .set("x-api-key", apiKey); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates ledger range query parameters - fromLedger > toLedger", async () => { + const response = await request(app) + .post("/api/admin/reindex?fromLedger=10&toLedger=5") + .set("x-api-key", apiKey); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + it("rejects quarantine list requests without API key", async () => { const response = await request(app).get("/api/admin/quarantine-events"); expect(response.status).toBe(401); }); - it("validates reprocess payload ids", async () => { + it("validates reprocess payload ids - non-integer id", async () => { const response = await request(app) .post("/api/admin/quarantine-events/reprocess") .set("x-api-key", apiKey) @@ -41,6 +68,36 @@ describe("Admin reindex endpoint", () => { expect(response.body.success).toBe(false); }); + it("validates reprocess payload ids - negative id", async () => { + const response = await request(app) + .post("/api/admin/quarantine-events/reprocess") + .set("x-api-key", apiKey) + .send({ ids: [1, -5] }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates reprocess payload limit - non-integer limit", async () => { + const response = await request(app) + .post("/api/admin/quarantine-events/reprocess") + .set("x-api-key", apiKey) + .send({ limit: "invalid" }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates reprocess payload limit - exceeds maximum", async () => { + const response = await request(app) + .post("/api/admin/quarantine-events/reprocess") + .set("x-api-key", apiKey) + .send({ limit: 501 }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + it("rejects check-defaults payloads with more than 1000 loan IDs", async () => { const loanIds = Array.from({ length: 1001 }, (_, index) => index + 1); const response = await request(app) diff --git a/src/__tests__/webhookValidation.test.ts b/src/__tests__/webhookValidation.test.ts new file mode 100644 index 0000000..2531432 --- /dev/null +++ b/src/__tests__/webhookValidation.test.ts @@ -0,0 +1,150 @@ +import request from "supertest"; +import app from "../app.js"; + +describe("Webhook subscription validation", () => { + const apiKey = "test-internal-api-key"; + + beforeAll(() => { + process.env.INTERNAL_API_KEY = apiKey; + }); + + it("rejects requests without API key", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .send({ + callbackUrl: "https://example.com/webhook", + eventTypes: ["LoanApproved"], + }); + + expect(response.status).toBe(401); + }); + + it("validates callbackUrl is required", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + eventTypes: ["LoanApproved"], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates callbackUrl is a valid URL", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "not-a-valid-url", + eventTypes: ["LoanApproved"], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates callbackUrl uses http or https protocol", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "ftp://example.com/webhook", + eventTypes: ["LoanApproved"], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates eventTypes is required", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "https://example.com/webhook", + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates eventTypes is an array", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "https://example.com/webhook", + eventTypes: "LoanApproved", + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates eventTypes has at least one element", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "https://example.com/webhook", + eventTypes: [], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("validates eventTypes contains only valid event types", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "https://example.com/webhook", + eventTypes: ["InvalidEventType"], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("accepts valid webhook subscription with secret", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "https://example.com/webhook", + eventTypes: ["LoanApproved"], + secret: "my-secret-key", + }); + + // Should not fail validation (may fail for other reasons like DB not being set up) + expect(response.status).not.toBe(400); + }); + + it("accepts valid webhook subscription without secret", async () => { + const response = await request(app) + .post("/api/admin/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "https://example.com/webhook", + eventTypes: ["LoanApproved", "LoanRepaid"], + }); + + // Should not fail validation (may fail for other reasons like DB not being set up) + expect(response.status).not.toBe(400); + }); + + it("validates indexer webhook endpoint", async () => { + const response = await request(app) + .post("/api/indexer/webhooks") + .set("x-api-key", apiKey) + .send({ + callbackUrl: "not-a-url", + eventTypes: ["LoanApproved"], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); +}); diff --git a/src/controllers/indexerController.ts b/src/controllers/indexerController.ts index dfd41ae..d5534f5 100644 --- a/src/controllers/indexerController.ts +++ b/src/controllers/indexerController.ts @@ -494,58 +494,21 @@ export const createWebhookSubscription = async ( ) => { try { const { callbackUrl, eventTypes, secret } = req.body as { - callbackUrl?: string; - eventTypes?: string[]; + callbackUrl: string; + eventTypes: WebhookEventType[]; secret?: string; }; - if (!callbackUrl) { - return res.status(400).json({ - success: false, - message: "callbackUrl is required", - }); - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(callbackUrl); - } catch { - return res.status(400).json({ - success: false, - message: "callbackUrl must be a valid URL", - }); - } - - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - return res.status(400).json({ - success: false, - message: "callbackUrl must use http or https", - }); - } - - const normalizedEventTypes = Array.isArray(eventTypes) - ? eventTypes.filter((eventType): eventType is WebhookEventType => - SUPPORTED_WEBHOOK_EVENT_TYPES.includes(eventType as WebhookEventType), - ) - : []; - - if (normalizedEventTypes.length === 0) { - return res.status(400).json({ - success: false, - message: `eventTypes must include at least one of: ${SUPPORTED_WEBHOOK_EVENT_TYPES.join(", ")}`, - }); - } - const subscription = await webhookService.registerSubscription( secret ? { callbackUrl, - eventTypes: normalizedEventTypes, + eventTypes, secret, } : { callbackUrl, - eventTypes: normalizedEventTypes, + eventTypes, }, ); @@ -634,31 +597,10 @@ export const getWebhookDeliveries = async (req: Request, res: Response) => { export const reindexLedgerRange = async (req: Request, res: Response) => { try { - const fromLedger = Number(req.query.fromLedger); - const toLedger = Number(req.query.toLedger); - - if (!Number.isInteger(fromLedger) || !Number.isInteger(toLedger)) { - return res.status(400).json({ - success: false, - message: "fromLedger and toLedger must be integers", - }); - } - - if (fromLedger <= 0 || toLedger <= 0 || fromLedger > toLedger) { - return res.status(400).json({ - success: false, - message: "Ledger range is invalid", - }); - } - - const maxRange = Number(process.env.REINDEX_MAX_RANGE ?? 25000); - const requestedRange = toLedger - fromLedger + 1; - if (requestedRange > maxRange) { - return res.status(400).json({ - success: false, - message: `Requested range exceeds maximum of ${maxRange} ledgers`, - }); - } + const { fromLedger, toLedger } = req.query as unknown as { + fromLedger: number; + toLedger: number; + }; let indexer: EventIndexer; try { @@ -742,34 +684,20 @@ export const reprocessQuarantinedEvents = async ( ) => { try { const { ids, limit } = req.body as { - ids?: unknown; - limit?: unknown; + ids?: number[]; + limit?: number; }; - const parsedIds = Array.isArray(ids) - ? ids.filter((id): id is number => Number.isInteger(id) && id > 0) - : undefined; - - if (Array.isArray(ids) && (!parsedIds || parsedIds.length !== ids.length)) { - return res.status(400).json({ - success: false, - message: "ids must be an array of positive integers", - }); - } - - const parsedLimit = - typeof limit === "number" && Number.isInteger(limit) && limit > 0 - ? Math.min(limit, 500) - : 50; + const parsedLimit = limit ?? 50; const rowsResult = - parsedIds && parsedIds.length > 0 + ids && ids.length > 0 ? await query( `SELECT id, event_id, ledger, tx_hash, contract_id, raw_xdr, error_message, quarantined_at FROM quarantine_events WHERE id = ANY($1::int[]) ORDER BY id ASC`, - [parsedIds], + [ids], ) : await query( `SELECT id, event_id, ledger, tx_hash, contract_id, raw_xdr, error_message, quarantined_at diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 6475dce..289caf8 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { requireApiKey } from "../middleware/auth.js"; import { requireJwtAuth, requireRoles } from "../middleware/jwtAuth.js"; import { strictRateLimiter } from "../middleware/rateLimiter.js"; -import { validateBody } from "../middleware/validation.js"; +import { validateBody, validateQuery } from "../middleware/validation.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { auditLog } from "../middleware/auditLog.js"; import { defaultChecker } from "../services/defaultChecker.js"; @@ -24,6 +24,11 @@ import { rejectLoanDispute, } from "../controllers/adminDisputeController.js"; import { approveLoanBodySchema } from "../schemas/loanSchemas.js"; +import { + createWebhookSubscriptionSchema, + reindexLedgerRangeQuerySchema, + reprocessQuarantinedEventsSchema, +} from "../schemas/indexerSchemas.js"; import { query } from "../db/connection.js"; const router = Router(); @@ -250,6 +255,7 @@ router.post( requireApiKey, strictRateLimiter, auditLog, + validateQuery(reindexLedgerRangeQuerySchema), reindexLedgerRange, ); @@ -310,6 +316,7 @@ router.post( requireApiKey, strictRateLimiter, auditLog, + validateBody(reprocessQuarantinedEventsSchema), reprocessQuarantinedEvents, ); @@ -350,6 +357,7 @@ router.post( requireApiKey, strictRateLimiter, auditLog, + validateBody(createWebhookSubscriptionSchema), createWebhookSubscription, ); diff --git a/src/routes/indexerRoutes.ts b/src/routes/indexerRoutes.ts index ec99a15..09d7c8f 100644 --- a/src/routes/indexerRoutes.ts +++ b/src/routes/indexerRoutes.ts @@ -15,6 +15,8 @@ import { requireWalletOwnership, } from "../middleware/jwtAuth.js"; import { requireLoanBorrowerAccess } from "../middleware/loanAccess.js"; +import { validateBody } from "../middleware/validation.js"; +import { createWebhookSubscriptionSchema } from "../schemas/indexerSchemas.js"; const router = Router(); @@ -209,7 +211,12 @@ router.get("/webhooks", requireApiKey, listWebhookSubscriptions); * 401: * description: Missing or invalid API key */ -router.post("/webhooks", requireApiKey, createWebhookSubscription); +router.post( + "/webhooks", + requireApiKey, + validateBody(createWebhookSubscriptionSchema), + createWebhookSubscription, +); /** * @swagger diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 3f59bfe..e4e29b8 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,3 +1,4 @@ export * from "./simulationSchemas.js"; export * from "./scoreSchemas.js"; export * from "./stellarSchemas.js"; +export * from "./indexerSchemas.js"; diff --git a/src/schemas/indexerSchemas.ts b/src/schemas/indexerSchemas.ts new file mode 100644 index 0000000..ae23600 --- /dev/null +++ b/src/schemas/indexerSchemas.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { SUPPORTED_WEBHOOK_EVENT_TYPES } from "../services/webhookService.js"; + +// Webhook creation schema +export const createWebhookSubscriptionSchema = z.object({ + callbackUrl: z + .string() + .url("callbackUrl must be a valid URL") + .refine( + (url) => { + try { + const protocol = new URL(url).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } + }, + { message: "callbackUrl must use http or https" }, + ), + eventTypes: z + .array( + z.enum( + SUPPORTED_WEBHOOK_EVENT_TYPES as unknown as readonly [ + string, + ...string[], + ], + ), + ) + .min( + 1, + `eventTypes must include at least one of: ${SUPPORTED_WEBHOOK_EVENT_TYPES.join( + ", ", + )}`, + ), + secret: z.string().optional(), +}); + +// Reindex query params schema +const reindexMaxRange = Number(process.env.REINDEX_MAX_RANGE ?? 25000); + +export const reindexLedgerRangeQuerySchema = z + .object({ + fromLedger: z.coerce + .number() + .int("fromLedger must be an integer") + .positive("fromLedger must be positive"), + toLedger: z.coerce + .number() + .int("toLedger must be an integer") + .positive("toLedger must be positive"), + }) + .refine((data) => data.fromLedger <= data.toLedger, { + message: "fromLedger must be less than or equal to toLedger", + }) + .refine((data) => data.toLedger - data.fromLedger + 1 <= reindexMaxRange, { + message: `Requested range exceeds maximum of ${reindexMaxRange} ledgers`, + }); + +// Quarantine reprocess body schema +export const reprocessQuarantinedEventsSchema = z.object({ + ids: z + .array(z.number().int().positive("ids must be positive integers")) + .optional(), + limit: z + .number() + .int("limit must be an integer") + .positive("limit must be positive") + .max(500, "limit must not exceed 500") + .optional(), +});