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
75 changes: 71 additions & 4 deletions backend/src/api/controllers/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import type { Request, Response, NextFunction } from "express";
import { query } from "../../db/index.js";
import { validateWebhookUrl } from "../../services/notifications.js";
import { validateWebhookUrl, NotificationService } from "../../services/notifications.js";

const notificationService = new NotificationService();

interface WebhookRow {
id: number;
url: string;
events: string[];
active: boolean;
created_at: Date;
consecutive_failures: number;
}

function formatWebhook(w: WebhookRow) {
return { id: w.id, url: w.url, events: w.events, active: w.active, createdAt: w.created_at };
return {
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.created_at,
consecutiveFailures: w.consecutive_failures ?? 0,
};
}

export async function createWebhook(req: Request, res: Response, next: NextFunction) {
Expand All @@ -28,7 +38,7 @@ export async function createWebhook(req: Request, res: Response, next: NextFunct
const rows = await query<WebhookRow>(
`INSERT INTO webhooks (url, events, secret)
VALUES ($1, $2, $3)
RETURNING id, url, events, active, created_at`,
RETURNING id, url, events, active, created_at, consecutive_failures`,
[url, events, secret ?? null],
);

Expand All @@ -41,7 +51,7 @@ export async function createWebhook(req: Request, res: Response, next: NextFunct
export async function listWebhooks(_req: Request, res: Response, next: NextFunction) {
try {
const rows = await query<WebhookRow>(
"SELECT id, url, events, active, created_at FROM webhooks WHERE active = TRUE ORDER BY created_at DESC",
"SELECT id, url, events, active, created_at, consecutive_failures FROM webhooks WHERE active = TRUE ORDER BY created_at DESC",
);

res.json(rows.map(formatWebhook));
Expand Down Expand Up @@ -69,3 +79,60 @@ export async function deleteWebhook(req: Request, res: Response, next: NextFunct
next(err);
}
}

/**
* POST /api/v1/admin/webhooks/:id/test
* Sends a test ping to the webhook URL and returns delivery metadata.
* Issue #666.
*/
export async function testWebhook(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(req.params["id"] as string, 10);
if (isNaN(id)) {
res.status(400).json({ error: "InvalidId", message: "Webhook ID must be a positive integer" });
return;
}

const rows = await query<WebhookRow>(
"SELECT id, url, events, active, created_at, consecutive_failures, secret FROM webhooks WHERE id = $1",
[id],
);

if (rows.length === 0) {
res.status(404).json({ error: "NotFound", message: "Webhook not found" });
return;
}

const webhook = rows[0] as WebhookRow & { secret: string | null };
const result = await notificationService.testDeliver(webhook);

res.json(result);
} catch (err) {
next(err);
}
}

/**
* POST /api/v1/webhooks/verify-signature
* Verifies an HMAC-SHA256 webhook signature.
* Issue #664.
*/
export async function verifyWebhookSignature(req: Request, res: Response, next: NextFunction) {
try {
const { payload, signature, secret } = req.body as {
payload: string;
signature: string;
secret: string;
};

if (typeof payload !== "string" || typeof signature !== "string" || typeof secret !== "string") {
res.status(400).json({ error: "BadRequest", message: "payload, signature, and secret are required strings" });
return;
}

const valid = notificationService.verifySignature(payload, signature, secret);
res.json({ valid });
} catch (err) {
next(err);
}
}
25 changes: 24 additions & 1 deletion backend/src/api/routes/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Router } from "express";
import { z } from "zod";
import { createWebhook, listWebhooks, deleteWebhook } from "../controllers/webhooks.js";
import {
createWebhook,
listWebhooks,
deleteWebhook,
testWebhook,
verifyWebhookSignature,
} from "../controllers/webhooks.js";
import { requireApiKey } from "../middleware/auth.js";
import { validateBody, validateParams } from "../middleware/validate.js";

Expand Down Expand Up @@ -32,10 +38,27 @@ const webhookParamsSchema = z.object({
id: z.string().regex(/^\d+$/, "ID must be a positive integer"),
});

/** Schema for POST /webhooks/verify-signature (#664) */
const verifySignatureSchema = z.object({
payload: z.string(),
signature: z.string(),
secret: z.string(),
});

export const webhooksRouter = Router();

webhooksRouter.use(requireApiKey());

webhooksRouter.post("/", validateBody(createWebhookSchema), createWebhook);
webhooksRouter.get("/", listWebhooks);
webhooksRouter.delete("/:id", validateParams(webhookParamsSchema), deleteWebhook);

/** POST /webhooks/verify-signature — verify HMAC signature (#664) */
webhooksRouter.post(
"/verify-signature",
validateBody(verifySignatureSchema),
verifyWebhookSignature,
);

/** POST /admin/webhooks/:id/test — send test ping (#666) */
webhooksRouter.post("/:id/test", validateParams(webhookParamsSchema), testWebhook);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Migration 023: Add consecutive_failures tracking to webhooks table
-- Required for auto-deactivation after sustained delivery failures (issue #667)

ALTER TABLE webhooks
ADD COLUMN IF NOT EXISTS consecutive_failures INT NOT NULL DEFAULT 0;
153 changes: 153 additions & 0 deletions backend/src/services/notifications-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotificationService } from "./notifications.js";

// Mock the db query function
vi.mock("../db/index.js", () => ({
query: vi.fn(),
}));

// Mock logger
vi.mock("../logger.js", () => ({
logger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
}));

import { query } from "../db/index.js";

const mockQuery = query as ReturnType<typeof vi.fn>;

describe("NotificationService.verifySignature (#664)", () => {
const svc = new NotificationService();

it("returns true for a correctly signed payload", () => {
const secret = "my-secret";
const payload = '{"event":"test"}';
// Compute expected signature manually
const { createHmac } = require("crypto");
const expected = `sha256=${createHmac("sha256", secret).update(payload).digest("hex")}`;
expect(svc.verifySignature(payload, expected, secret)).toBe(true);
});

it("returns false for a tampered payload", () => {
const secret = "my-secret";
const originalPayload = '{"event":"test"}';
const tamperedPayload = '{"event":"tampered"}';
const { createHmac } = require("crypto");
const sig = `sha256=${createHmac("sha256", secret).update(originalPayload).digest("hex")}`;
expect(svc.verifySignature(tamperedPayload, sig, secret)).toBe(false);
});

it("returns false for a wrong secret", () => {
const payload = '{"event":"test"}';
const { createHmac } = require("crypto");
const sig = `sha256=${createHmac("sha256", "correct-secret").update(payload).digest("hex")}`;
expect(svc.verifySignature(payload, sig, "wrong-secret")).toBe(false);
});

it("returns false when signature length differs", () => {
expect(svc.verifySignature("payload", "short", "secret")).toBe(false);
});
});

describe("NotificationService.notify — consecutive_failures tracking (#667)", () => {
let svc: NotificationService;

beforeEach(() => {
svc = new NotificationService();
mockQuery.mockReset();
vi.stubGlobal("fetch", vi.fn());
});

it("increments consecutive_failures on delivery failure", async () => {
const webhookRow = {
id: 1,
url: "https://example.com/hook",
events: ["deposit"],
secret: null,
consecutive_failures: 0,
};

// SELECT webhooks returns one webhook
mockQuery.mockResolvedValueOnce([webhookRow]);

// fetch returns non-2xx
(fetch as any).mockResolvedValueOnce({ ok: false, status: 500 } as Response);

// Mock validateWebhookUrl to succeed (no DNS calls in unit test)
const notifMod = await import("./notifications.js");
vi.spyOn(notifMod, "validateWebhookUrl").mockResolvedValue(undefined);

// UPDATE consecutive_failures
mockQuery.mockResolvedValueOnce([]);
// INSERT webhook_deliveries
mockQuery.mockResolvedValueOnce([]);

await svc.notify("deposit", { amount: 100 });

// The second query call should be the UPDATE for consecutive_failures = 1
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("consecutive_failures"),
[1, 1],
);
});

it("resets consecutive_failures to 0 on success", async () => {
const webhookRow = {
id: 2,
url: "https://example.com/hook",
events: ["deposit"],
secret: null,
consecutive_failures: 5,
};

mockQuery.mockResolvedValueOnce([webhookRow]);

// fetch succeeds
(fetch as any).mockResolvedValueOnce({ ok: true, status: 200 } as Response);

const notifMod = await import("./notifications.js");
vi.spyOn(notifMod, "validateWebhookUrl").mockResolvedValue(undefined);

// UPDATE consecutive_failures = 0
mockQuery.mockResolvedValueOnce([]);

await svc.notify("deposit", { amount: 50 });

expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("consecutive_failures = 0"),
[2],
);
});

it("auto-deactivates webhook after 10 consecutive failures", async () => {
const webhookRow = {
id: 3,
url: "https://example.com/hook",
events: ["deposit"],
secret: null,
consecutive_failures: 9, // one more will reach 10
};

mockQuery.mockResolvedValueOnce([webhookRow]);

(fetch as any).mockResolvedValueOnce({ ok: false, status: 500 } as Response);

const notifMod = await import("./notifications.js");
vi.spyOn(notifMod, "validateWebhookUrl").mockResolvedValue(undefined);

// UPDATE with active = FALSE
mockQuery.mockResolvedValueOnce([]);
// INSERT webhook_deliveries
mockQuery.mockResolvedValueOnce([]);

await svc.notify("deposit", {});

expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("active = FALSE"),
[10, 3],
);
});
});
Loading
Loading