diff --git a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts new file mode 100644 index 000000000..10819a415 --- /dev/null +++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Module-boundary mocks must come before importing the route. --- + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ + setAuth: vi.fn(), + addWarning: vi.fn(), + })), +})); + +import { POST, GET } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getEvent } from "@/lib/logging/context"; + +const ORIGINAL_SECRET = process.env.DEPLOY_WEBHOOK_SECRET; + +beforeEach(() => { + vi.resetAllMocks(); + process.env.DEPLOY_WEBHOOK_SECRET = "test-secret"; + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 60, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(getEvent).mockReturnValue({ + setAuth: vi.fn(), + addWarning: vi.fn(), + } as never); + vi.mocked(prisma.user.findMany).mockResolvedValue([] as never); +}); + +afterEach(() => { + if (ORIGINAL_SECRET === undefined) delete process.env.DEPLOY_WEBHOOK_SECRET; + else process.env.DEPLOY_WEBHOOK_SECRET = ORIGINAL_SECRET; +}); + +function jsonRequest( + body: unknown, + headers: Record = {}, +): NextRequest { + return new NextRequest("http://localhost/api/internal/deploy-webhook", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/internal/deploy-webhook", () => { + it("returns 401 with no secret header", async () => { + const res = await POST(jsonRequest({ status: "success" })); + expect(res.status).toBe(401); + expect(auditLog).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("returns 401 with a wrong secret", async () => { + const res = await POST( + jsonRequest( + { status: "success" }, + { "x-deploy-webhook-secret": "WRONG" }, + ), + ); + expect(res.status).toBe(401); + expect(auditLog).not.toHaveBeenCalled(); + }); + + it("returns 401 when DEPLOY_WEBHOOK_SECRET env var is unset, regardless of header", async () => { + delete process.env.DEPLOY_WEBHOOK_SECRET; + const addWarning = vi.fn(); + vi.mocked(getEvent).mockReturnValue({ + setAuth: vi.fn(), + addWarning, + } as never); + + const res = await POST( + jsonRequest( + { status: "success" }, + { "x-deploy-webhook-secret": "anything" }, + ), + ); + expect(res.status).toBe(401); + expect(addWarning).toHaveBeenCalledWith( + "DEPLOY_WEBHOOK_SECRET not configured", + ); + }); + + it("happy path: success payload writes system.deploy.success audit row, no admin alert", async () => { + const res = await POST( + jsonRequest( + { + status: "success", + application_name: "HealthLog", + application_uuid: "pg8wggwogo8c4gc4ks0kk4ss", + deployment_uuid: "dep-123", + }, + { "x-deploy-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; outcome: string }; + expect(body).toEqual({ status: "ok", outcome: "success" }); + + expect(auditLog).toHaveBeenCalledTimes(1); + expect(auditLog).toHaveBeenCalledWith( + "system.deploy.success", + expect.objectContaining({ + details: expect.objectContaining({ + applicationName: "HealthLog", + applicationUuid: "pg8wggwogo8c4gc4ks0kk4ss", + deploymentUuid: "dep-123", + error: null, + raw: expect.objectContaining({ status: "success" }), + }), + }), + ); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("failure payload writes system.deploy.failure audit row AND fans a SYSTEM_ALERT to every admin", async () => { + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ + { id: "admin-1" }, + { id: "admin-2" }, + ] as never); + + const res = await POST( + jsonRequest( + { + status: "failed", + application_name: "HealthLog", + application_uuid: "pg8wggwogo8c4gc4ks0kk4ss", + deployment_uuid: "dep-456", + error: "container exited 137", + }, + { "x-deploy-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { outcome: string }; + expect(body.outcome).toBe("failure"); + + expect(auditLog).toHaveBeenCalledWith( + "system.deploy.failure", + expect.objectContaining({ + details: expect.objectContaining({ + error: "container exited 137", + }), + }), + ); + + expect(dispatchNotification).toHaveBeenCalledTimes(2); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "SYSTEM_ALERT", + userId: "admin-1", + title: expect.stringContaining("Deploy failed"), + message: expect.stringContaining("container exited 137"), + metadata: expect.objectContaining({ + source: "deploy-webhook", + deploymentUuid: "dep-456", + }), + }), + ); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ userId: "admin-2" }), + ); + }); + + it("treats unknown statuses (e.g. 'queued') as system.deploy.unknown without alerting", async () => { + const res = await POST( + jsonRequest( + { status: "queued", application_name: "HealthLog" }, + { "x-deploy-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { outcome: string }; + expect(body.outcome).toBe("unknown"); + expect(auditLog).toHaveBeenCalledWith( + "system.deploy.unknown", + expect.any(Object), + ); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("emits a Wide Event warning when a deploy fails but no admin user exists", async () => { + const addWarning = vi.fn(); + vi.mocked(getEvent).mockReturnValue({ + setAuth: vi.fn(), + addWarning, + } as never); + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([] as never); + + await POST( + jsonRequest( + { status: "failed", application_name: "HealthLog" }, + { "x-deploy-webhook-secret": "test-secret" }, + ), + ); + + expect(addWarning).toHaveBeenCalledWith( + expect.stringMatching(/no admin user/i), + ); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("returns 429 and skips audit + alert when rate-limited", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const res = await POST( + jsonRequest( + { status: "success" }, + { "x-deploy-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(429); + expect(auditLog).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("tolerates malformed (non-string) status fields by treating them as unknown", async () => { + const res = await POST( + jsonRequest( + { status: 42 }, + { "x-deploy-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { outcome: string }; + expect(body.outcome).toBe("unknown"); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("preserves the full Coolify payload in the audit-log raw field for forward compat", async () => { + const fullPayload = { + status: "success", + application_name: "HealthLog", + application_uuid: "pg8wggwogo8c4gc4ks0kk4ss", + deployment_uuid: "dep-789", + future_field: { commit_sha: "abc123", environment: "production" }, + }; + + await POST( + jsonRequest(fullPayload, { "x-deploy-webhook-secret": "test-secret" }), + ); + + expect(auditLog).toHaveBeenCalledWith( + "system.deploy.success", + expect.objectContaining({ + details: expect.objectContaining({ + raw: expect.objectContaining({ + future_field: { commit_sha: "abc123", environment: "production" }, + }), + }), + }), + ); + }); +}); + +describe("GET /api/internal/deploy-webhook (reachability check)", () => { + it("returns 200 with valid secret header", async () => { + const req = new NextRequest("http://localhost/api/internal/deploy-webhook", { + method: "GET", + headers: { "x-deploy-webhook-secret": "test-secret" }, + }); + const res = await GET(req); + expect(res.status).toBe(200); + }); + + it("returns 401 without a secret", async () => { + const req = new NextRequest("http://localhost/api/internal/deploy-webhook", { + method: "GET", + }); + const res = await GET(req); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts new file mode 100644 index 000000000..712aa89de --- /dev/null +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -0,0 +1,222 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { getClientIp, safeJson } from "@/lib/api-response"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { prisma } from "@/lib/db"; + +/** + * Deploy-status webhook (phase C2 / v1.4.15). + * + * Coolify (Settings → Notifications → Webhook) calls this endpoint after + * every deploy attempt. We verify the request via a shared secret in the + * `X-Deploy-Webhook-Secret` header (timing-safe compare against + * `process.env.DEPLOY_WEBHOOK_SECRET`), audit-log the event, and on + * failure page the admin via Telegram if the channel is configured. + * + * Why a *separate* webhook for status when Coolify already has a deploy + * trigger URL: the trigger is one-shot ("queue a deploy"); the status + * comes back asynchronously when the container is healthy / fails to + * start. Without this surface we lose visibility once the deploy is + * fired — exactly the gap that produced v1.4.6..v1.4.14's manual + * verification recipe (Marc had to SSH and check digests). + * + * Payload shape: Coolify sends a free-form JSON object whose exact + * fields drift between beta releases. We treat it as `Record` and only extract a small set of well-known keys + * (`status`, `application_name`, `application_uuid`, `deployment_uuid`, + * `error`) defensively, falling back to "unknown" when missing. The + * payload itself is stored in the audit-log `details` field so a + * future Coolify upgrade that adds richer metadata becomes + * automatically available without code change. + */ + +interface CoolifyDeployPayload { + status?: unknown; + application_name?: unknown; + application_uuid?: unknown; + deployment_uuid?: unknown; + error?: unknown; + [key: string]: unknown; +} + +interface NormalizedEvent { + outcome: "success" | "failure" | "unknown"; + applicationName: string; + applicationUuid: string | null; + deploymentUuid: string | null; + error: string | null; + raw: CoolifyDeployPayload; +} + +function asStringOr(value: unknown, fallback: T): string | T { + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function classifyOutcome(status: unknown): NormalizedEvent["outcome"] { + if (typeof status !== "string") return "unknown"; + const s = status.toLowerCase(); + // Coolify uses "success" / "finished" for happy path and + // "failed" / "error" / "stopped" for unhappy. Anything else is + // logged but doesn't trigger a Telegram page (avoids alert fatigue + // on intermediate states like "queued" or "in_progress"). + if (s === "success" || s === "finished" || s === "succeeded") return "success"; + if (s === "failed" || s === "error" || s === "stopped" || s === "failure") { + return "failure"; + } + return "unknown"; +} + +function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent { + return { + outcome: classifyOutcome(payload.status), + applicationName: asStringOr(payload.application_name, "unknown"), + applicationUuid: asStringOr(payload.application_uuid, null), + deploymentUuid: asStringOr(payload.deployment_uuid, null), + error: asStringOr(payload.error, null), + raw: payload, + }; +} + +function hasValidSecret(request: NextRequest): boolean { + const expected = process.env.DEPLOY_WEBHOOK_SECRET; + if (!expected) { + getEvent()?.addWarning("DEPLOY_WEBHOOK_SECRET not configured"); + return false; + } + const received = request.headers.get("x-deploy-webhook-secret"); + if (!received) return false; + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(received, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Reuse the `maybeAlertAdmins()` pattern from + * `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the + * existing dispatcher to every user whose role is ADMIN. The dispatcher + * silently no-ops when no Telegram channel is configured, so this is + * safe even on a first-deploy where Marc hasn't wired the bot yet. + */ +async function notifyAdminsOfFailure(event: NormalizedEvent): Promise { + const admins = await prisma.user.findMany({ + where: { role: "ADMIN" }, + select: { id: true }, + }); + + if (admins.length === 0) { + getEvent()?.addWarning( + "No admin user configured to alert about deploy failure", + ); + return; + } + + const title = `Deploy failed: ${event.applicationName}`; + const message = [ + `The Coolify deploy for ${event.applicationName} reported a failure.`, + event.error ? `Error: ${event.error}` : null, + event.deploymentUuid ? `Deployment: ${event.deploymentUuid}` : null, + "Logs: https://apps-01.bombeck.io", + ] + .filter((line): line is string => line !== null) + .join("\n"); + + for (const admin of admins) { + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: admin.id, + title, + message, + metadata: { + source: "deploy-webhook", + applicationName: event.applicationName, + applicationUuid: event.applicationUuid, + deploymentUuid: event.deploymentUuid, + }, + }); + } +} + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "deploy.webhook" } }); + + // Rate-limit by client IP. 60 requests / minute is comfortably above + // Coolify's per-event burst (typically 1-2 per deploy) but well below + // a hostile actor flooding to fish for a working secret. + const ip = getClientIp(request); + const rl = await checkRateLimit(`deploy-webhook:${ip ?? "unknown"}`, 60, 60_000); + if (!rl.allowed) { + return NextResponse.json( + { status: "rate_limited" }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + if (!hasValidSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + + getEvent()?.setAuth({ auth_method: "webhook_secret" }); + + const { data: payload, error: jsonError } = + await safeJson(request); + if (jsonError) return jsonError; + + const event = normalizePayload(payload); + + annotate({ + meta: { + deploy_outcome: event.outcome, + application_name: event.applicationName, + application_uuid: event.applicationUuid, + deployment_uuid: event.deploymentUuid, + }, + }); + + // One audit-log row per webhook call. Action namespace mirrors the + // existing `system.*` lane (`system.error`, `system.cleanup.*`) for + // ops-style events that aren't tied to a single user. + const action = + event.outcome === "success" + ? "system.deploy.success" + : event.outcome === "failure" + ? "system.deploy.failure" + : "system.deploy.unknown"; + + await auditLog(action, { + ipAddress: ip ?? null, + details: { + applicationName: event.applicationName, + applicationUuid: event.applicationUuid, + deploymentUuid: event.deploymentUuid, + error: event.error, + // Preserve the full Coolify payload so we can adapt to schema + // drift without code change. + raw: event.raw, + }, + }); + + if (event.outcome === "failure") { + await notifyAdminsOfFailure(event); + } + + return NextResponse.json({ status: "ok", outcome: event.outcome }, { status: 200 }); +}); + +/** + * Coolify (and most webhook UIs) ping `GET` for a reachability check + * before saving the configuration. We respond 200 if the secret is + * valid, 401 otherwise — same shape as the Withings webhook. + */ +export const GET = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "deploy.webhook.verify" } }); + if (!hasValidSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + getEvent()?.setAuth({ auth_method: "webhook_secret" }); + return NextResponse.json({ status: "ok" }, { status: 200 }); +});