Skip to content
Open
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
4 changes: 2 additions & 2 deletions migrations/1776000000006_add-interest-rate-to-loan-events.js
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -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"]);
};
61 changes: 59 additions & 2 deletions src/__tests__/adminReindex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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)
Expand Down
150 changes: 150 additions & 0 deletions src/__tests__/webhookValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
98 changes: 13 additions & 85 deletions src/controllers/indexerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading