import { createHmac } from "node:crypto"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Test, type TestingModule } from "@nestjs/testing"; import type { Request } from "express"; import { describe, beforeEach, expect, it, vi } from "vitest"; import { QueueNotificationsService } from "./queue-notifications.service"; import { type WoodpeckerWebhookPayload, WoodpeckerWebhookController, } from "./woodpecker-webhook.controller"; function signPayload(payload: WoodpeckerWebhookPayload, secret: string): string { return createHmac("sha256", secret).update(JSON.stringify(payload)).digest("hex"); } describe("WoodpeckerWebhookController", () => { let controller: WoodpeckerWebhookController; const mockService = { notifyAgentCiResult: vi.fn(), }; const mockConfigService = { get: vi.fn(), }; beforeEach(async () => { vi.clearAllMocks(); mockConfigService.get.mockImplementation((key: string) => { if (key === "WOODPECKER_WEBHOOK_SECRET") { return "test-secret"; } return undefined; }); const module: TestingModule = await Test.createTestingModule({ controllers: [WoodpeckerWebhookController], providers: [ { provide: QueueNotificationsService, useValue: mockService }, { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); controller = module.get(WoodpeckerWebhookController); }); it("accepts a valid signature and forwards the payload to the service", async () => { const payload: WoodpeckerWebhookPayload = { branch: "feat/ms24-ci-webhook", status: "success", buildUrl: "https://ci.example/build/123", repo: "mosaic/stack", }; const signature = signPayload(payload, "test-secret"); mockService.notifyAgentCiResult.mockResolvedValue({ notified: 2 }); await expect( controller.handleWebhook( { rawBody: Buffer.from(JSON.stringify(payload)) } as Request, payload, signature ) ).resolves.toEqual({ ok: true, notified: 2 }); expect(mockService.notifyAgentCiResult).toHaveBeenCalledWith(payload); }); it("returns ok without notifying when the signature is invalid", async () => { const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); const payload: WoodpeckerWebhookPayload = { branch: "feat/ms24-ci-webhook", status: "failure", buildUrl: "https://ci.example/build/123", repo: "mosaic/stack", }; await expect( controller.handleWebhook( { rawBody: Buffer.from(JSON.stringify(payload)) } as Request, payload, "bad-signature" ) ).resolves.toEqual({ ok: true, notified: 0 }); expect(mockService.notifyAgentCiResult).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("invalid Woodpecker webhook signature") ); }); it("accepts the payload when the webhook secret is missing", async () => { const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); const payload: WoodpeckerWebhookPayload = { branch: "feat/ms24-ci-webhook", status: "success", buildUrl: "https://ci.example/build/123", repo: "mosaic/stack", }; mockConfigService.get.mockReturnValue(undefined); mockService.notifyAgentCiResult.mockResolvedValue({ notified: 1 }); await expect(controller.handleWebhook({} as Request, payload, "")).resolves.toEqual({ ok: true, notified: 1, }); expect(mockService.notifyAgentCiResult).toHaveBeenCalledWith(payload); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("WOODPECKER_WEBHOOK_SECRET is not configured") ); }); it("returns zero notifications when no active branch matches", async () => { const payload: WoodpeckerWebhookPayload = { branch: "feat/ms24-ci-webhook", status: "success", buildUrl: "https://ci.example/build/999", repo: "mosaic/stack", }; mockService.notifyAgentCiResult.mockResolvedValue({ notified: 0 }); await expect( controller.handleWebhook( { rawBody: Buffer.from(JSON.stringify(payload)) } as Request, payload, signPayload(payload, "test-secret") ) ).resolves.toEqual({ ok: true, notified: 0 }); }); });