Files
stack/apps/api/src/queue-notifications/woodpecker-webhook.controller.spec.ts
T

164 lines
5.3 KiB
TypeScript

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 { GatekeeperService } from "../gatekeeper/gatekeeper.service";
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 mockGatekeeperService = {
handleCiEvent: 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: GatekeeperService, useValue: mockGatekeeperService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
controller = module.get<WoodpeckerWebhookController>(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",
prNumber: 42,
headSha: "abcdef1234567890",
};
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);
expect(mockGatekeeperService.handleCiEvent).toHaveBeenCalledWith(
"mosaic/stack",
42,
"abcdef1234567890",
"success"
);
});
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 });
});
it("does not call Gatekeeper when the PR metadata is missing", async () => {
const payload: WoodpeckerWebhookPayload = {
branch: "feat/ms24-ci-webhook",
status: "success",
buildUrl: "https://ci.example/build/555",
repo: "mosaic/stack",
};
mockService.notifyAgentCiResult.mockResolvedValue({ notified: 1 });
await controller.handleWebhook(
{ rawBody: Buffer.from(JSON.stringify(payload)) } as Request,
payload,
signPayload(payload, "test-secret")
);
expect(mockGatekeeperService.handleCiEvent).not.toHaveBeenCalled();
});
});