From 70fdf45dd8cb73c19b9d351a28e59c53b22939ef Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 29 Jun 2026 14:55:44 +0100 Subject: [PATCH 1/2] test: add integration tests for webhook registration endpoints (#690) --- .../controllers/webhooks.integration.test.ts | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 backend/src/api/controllers/webhooks.integration.test.ts diff --git a/backend/src/api/controllers/webhooks.integration.test.ts b/backend/src/api/controllers/webhooks.integration.test.ts new file mode 100644 index 0000000..6d87257 --- /dev/null +++ b/backend/src/api/controllers/webhooks.integration.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import supertest from "supertest"; + +// Mock the database layer and the notification service so the HTTP stack runs +// end-to-end (routing, auth, validation, controllers) without a real DB, +// outbound DNS lookups, or network calls. +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + validateWebhookUrl: vi.fn(), +})); + +vi.mock("../../db/index.js", () => ({ + query: mocks.query, + pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, +})); + +vi.mock("../../services/notifications.js", () => ({ + validateWebhookUrl: mocks.validateWebhookUrl, + NotificationService: vi.fn(() => ({ + testDeliver: vi.fn(), + verifySignature: vi.fn(), + })), +})); + +import { webhooksRouter } from "../routes/webhooks.js"; +import { errorHandler } from "../middleware/errors.js"; + +// Mount the real webhooks router (auth + validation + controllers) on a +// minimal app, mirroring how app.ts wires it under /api/v1/webhooks. +function buildApp() { + const app = express(); + app.use(express.json()); + app.use("/api/v1/webhooks", webhooksRouter); + app.use(errorHandler); + return app; +} + +const request = supertest(buildApp()); + +const AUTH = "Bearer test-admin-key"; +const API_KEY_ROW = { id: 1, role: "admin", label: "test" }; + +/** + * Routes the shared `query` mock by SQL so each call (auth lookup vs. the + * controller's own query) gets the right response regardless of order. + */ +function routeQuery(handlers: { + apiKey?: unknown[]; + webhooks?: unknown[] | (() => unknown[]); +}) { + mocks.query.mockImplementation((sql: string) => { + if (sql.includes("api_keys")) { + return Promise.resolve(handlers.apiKey ?? [API_KEY_ROW]); + } + const webhooks = handlers.webhooks ?? []; + return Promise.resolve(typeof webhooks === "function" ? webhooks() : webhooks); + }); +} + +describe("Webhook registration endpoints (#690)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.validateWebhookUrl.mockResolvedValue(undefined); + }); + + describe("POST /api/v1/webhooks", () => { + it("creates a webhook and returns 201 for a valid body", async () => { + const created = { + id: 1, + url: "https://example.com/hook", + events: ["deposit"], + active: true, + created_at: new Date("2024-01-01T00:00:00.000Z"), + consecutive_failures: 0, + }; + routeQuery({ webhooks: [created] }); + + const res = await request + .post("/api/v1/webhooks") + .set("Authorization", AUTH) + .send({ url: "https://example.com/hook", events: ["deposit"] }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + id: 1, + url: "https://example.com/hook", + events: ["deposit"], + active: true, + }); + }); + + it("returns 400 when the URL is not HTTPS", async () => { + routeQuery({}); + + const res = await request + .post("/api/v1/webhooks") + .set("Authorization", AUTH) + .send({ url: "http://example.com/hook", events: ["deposit"] }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("ValidationError"); + // Validation must reject before the controller inserts anything. + expect(mocks.query).not.toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO webhooks"), + expect.anything(), + ); + }); + + it("returns 400 when the events array is missing", async () => { + routeQuery({}); + + const res = await request + .post("/api/v1/webhooks") + .set("Authorization", AUTH) + .send({ url: "https://example.com/hook" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("ValidationError"); + }); + + it("returns 400 when the events array is empty", async () => { + routeQuery({}); + + const res = await request + .post("/api/v1/webhooks") + .set("Authorization", AUTH) + .send({ url: "https://example.com/hook", events: [] }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("ValidationError"); + }); + + it("returns 401 when no Authorization header is sent", async () => { + routeQuery({}); + + const res = await request + .post("/api/v1/webhooks") + .send({ url: "https://example.com/hook", events: ["deposit"] }); + + expect(res.status).toBe(401); + }); + }); + + describe("GET /api/v1/webhooks", () => { + it("returns 401 without authentication", async () => { + routeQuery({}); + + const res = await request.get("/api/v1/webhooks"); + + expect(res.status).toBe(401); + expect(res.body).toMatchObject({ error: "Unauthorized" }); + }); + + it("returns 200 and an array of webhooks with valid auth", async () => { + routeQuery({ + webhooks: [ + { + id: 1, + url: "https://a.com/hook", + events: ["deposit"], + active: true, + created_at: new Date("2024-01-01T00:00:00.000Z"), + consecutive_failures: 0, + }, + { + id: 2, + url: "https://b.com/hook", + events: ["vault_created"], + active: true, + created_at: new Date("2024-01-02T00:00:00.000Z"), + consecutive_failures: 0, + }, + ], + }); + + const res = await request.get("/api/v1/webhooks").set("Authorization", AUTH); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(2); + expect(res.body[0]).toMatchObject({ id: 1, url: "https://a.com/hook" }); + // Secrets must never be exposed in the listing. + res.body.forEach((w: Record) => { + expect(w).not.toHaveProperty("secret"); + }); + }); + + it("returns 200 and an empty array when there are no webhooks", async () => { + routeQuery({ webhooks: [] }); + + const res = await request.get("/api/v1/webhooks").set("Authorization", AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + }); + + describe("DELETE /api/v1/webhooks/:id", () => { + it("returns 404 when the webhook does not exist", async () => { + routeQuery({ webhooks: [] }); + + const res = await request + .delete("/api/v1/webhooks/999") + .set("Authorization", AUTH); + + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "NotFound" }); + }); + + it("returns 204 on successful deletion", async () => { + routeQuery({ webhooks: [{ id: 5 }] }); + + const res = await request + .delete("/api/v1/webhooks/5") + .set("Authorization", AUTH); + + expect(res.status).toBe(204); + expect(res.body).toEqual({}); + }); + + it("returns 400 for a non-numeric id (param validation)", async () => { + routeQuery({}); + + const res = await request + .delete("/api/v1/webhooks/not-a-number") + .set("Authorization", AUTH); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("ValidationError"); + }); + + it("returns 401 without authentication", async () => { + routeQuery({}); + + const res = await request.delete("/api/v1/webhooks/5"); + + expect(res.status).toBe(401); + }); + }); +}); From 37f212233de42f57c08271a79c8b36a4dfde6c58 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 29 Jun 2026 14:55:44 +0100 Subject: [PATCH 2/2] test: add unit tests for requireApiKey middleware (#693) --- backend/src/api/middleware/auth.test.ts | 165 ++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 backend/src/api/middleware/auth.test.ts diff --git a/backend/src/api/middleware/auth.test.ts b/backend/src/api/middleware/auth.test.ts new file mode 100644 index 0000000..c402509 --- /dev/null +++ b/backend/src/api/middleware/auth.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHash } from "crypto"; + +// Mock the database layer so the middleware never touches a real DB. +vi.mock("../../db/index.js", () => ({ query: vi.fn() })); + +async function getTestContext() { + const { query } = await import("../../db/index.js"); + const { requireApiKey } = await import("./auth.js"); + return { query: query as ReturnType, requireApiKey }; +} + +function makeReqRes(authHeader?: string) { + const req = { headers: authHeader ? { authorization: authHeader } : {} } as any; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as any; + const next = vi.fn(); + return { req, res, next }; +} + +function sha256Hex(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +describe("requireApiKey middleware (#693)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("missing or malformed Authorization header", () => { + it("returns 401 Unauthorized when no Authorization header is present", async () => { + const { query, requireApiKey } = await getTestContext(); + const { req, res, next } = makeReqRes(); + + await requireApiKey()(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: "Unauthorized", + message: "Missing API key", + }); + expect(next).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("returns 401 when the header is missing the 'Bearer ' prefix", async () => { + const { query, requireApiKey } = await getTestContext(); + const { req, res, next } = makeReqRes("my-secret-key"); + + await requireApiKey()(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: "Unauthorized", + message: "Missing API key", + }); + expect(next).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("returns 401 for a non-Bearer scheme such as Basic auth", async () => { + const { requireApiKey } = await getTestContext(); + const { req, res, next } = makeReqRes("Basic dXNlcjpwYXNz"); + + await requireApiKey()(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe("invalid API key", () => { + it("returns 403 Forbidden when the key is not found in the database", async () => { + const { query, requireApiKey } = await getTestContext(); + query.mockResolvedValue([]); + const { req, res, next } = makeReqRes("Bearer unknown-key"); + + await requireApiKey()(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Forbidden", + message: "Invalid API key", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("treats a database error as an invalid key (fails closed with 403)", async () => { + const { query, requireApiKey } = await getTestContext(); + query.mockRejectedValue(new Error("connection refused")); + const { req, res, next } = makeReqRes("Bearer some-key"); + + await requireApiKey()(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Forbidden", + message: "Invalid API key", + }); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe("role enforcement", () => { + it("returns 403 when a valid key has a role other than the required one", async () => { + const { query, requireApiKey } = await getTestContext(); + query.mockResolvedValue([{ id: 1, role: "user", label: "read-only" }]); + const { req, res, next } = makeReqRes("Bearer user-key"); + + await requireApiKey({ role: "admin" })(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Forbidden", + message: "Insufficient permissions", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("calls next() and attaches req.apiKey for a valid admin key", async () => { + const { query, requireApiKey } = await getTestContext(); + const apiKey = { id: 7, role: "admin", label: "ci-bot" }; + query.mockResolvedValue([apiKey]); + const { req, res, next } = makeReqRes("Bearer admin-key"); + + await requireApiKey({ role: "admin" })(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(); + expect(req.apiKey).toEqual(apiKey); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("calls next() for any valid key when no role is required", async () => { + const { query, requireApiKey } = await getTestContext(); + query.mockResolvedValue([{ id: 9, role: "user", label: null }]); + const { req, res, next } = makeReqRes("Bearer user-key"); + + await requireApiKey()(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe("key hashing", () => { + it("looks the key up by its SHA-256 hash, never the plaintext", async () => { + const { query, requireApiKey } = await getTestContext(); + query.mockResolvedValue([{ id: 1, role: "admin", label: null }]); + const plaintext = "super-secret-token"; + const { req, res, next } = makeReqRes(`Bearer ${plaintext}`); + + await requireApiKey({ role: "admin" })(req, res, next); + + expect(query).toHaveBeenCalledWith( + expect.stringContaining("FROM api_keys"), + [sha256Hex(plaintext)], + ); + const [, params] = query.mock.calls[0]; + expect(params[0]).not.toBe(plaintext); + }); + }); +});