164 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|