From 008296238b3cc3f8b63399ea474fb4f8bd2e455f Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 27 May 2026 21:12:53 +0200 Subject: [PATCH] test(e2e): add test suite to test webhooks (#922) --- docker-compose.yml | 1 + e2e/0006-notifications-webhooks.spec.ts | 172 ++++++++++++++++++++++++ e2e/helpers/webhook-receiver.ts | 57 ++++++++ 3 files changed, 230 insertions(+) create mode 100644 e2e/0006-notifications-webhooks.spec.ts create mode 100644 e2e/helpers/webhook-receiver.ts diff --git a/docker-compose.yml b/docker-compose.yml index 32f2deda..adca5d16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b - BASE_URL=http://localhost:4096 - TRUSTED_ORIGINS=https://tinyauth.example.com:5557,https://localhost:5557 + - WEBHOOK_ALLOWED_ORIGINS=http://host.docker.internal:18080 - NODE_EXTRA_CA_CERTS=/tinyauth-ca/caddy/pki/authorities/local/root.crt - SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt devices: diff --git a/e2e/0006-notifications-webhooks.spec.ts b/e2e/0006-notifications-webhooks.spec.ts new file mode 100644 index 00000000..db373239 --- /dev/null +++ b/e2e/0006-notifications-webhooks.spec.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "./test"; +import { gotoAndWaitForAppReady } from "./helpers/page"; +import { startWebhookReceiver } from "./helpers/webhook-receiver"; + +const receiverPort = 18080; +const receiverOrigin = `http://host.docker.internal:${receiverPort}`; +const testDataPath = path.join(process.cwd(), "playwright", "temp"); + +type BackupSchedule = { + shortId: string; + lastBackupStatus: "success" | "error" | "in_progress" | "warning" | null; + lastBackupError: string | null; +}; + +function prepareTestFile(runId: string) { + const runPath = path.join(testDataPath, runId); + fs.mkdirSync(runPath, { recursive: true }); + fs.writeFileSync(path.join(runPath, "notification-webhook-test.json"), JSON.stringify({ runId })); + return `/test-data/${runId}`; +} + +test("delivers notification destinations and backup lifecycle webhooks", async ({ page }, testInfo) => { + const receiver = await startWebhookReceiver(receiverPort); + + try { + const runId = `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`; + const sourcePath = prepareTestFile(runId); + const repositoryPath = `/var/lib/zerobyte/data/repos/${runId}`; + + await gotoAndWaitForAppReady(page, "/"); + await expect(page).toHaveURL("/volumes"); + + const volumeResponse = await page.request.post("/api/v1/volumes", { + data: { + name: `Volume-${runId}`, + config: { backend: "directory", path: sourcePath, readOnly: false }, + }, + }); + expect(volumeResponse.ok()).toBe(true); + const volume = (await volumeResponse.json()) as { shortId: string }; + + const repositoryResponse = await page.request.post("/api/v1/repositories", { + data: { + name: `Repo-${runId}`, + config: { backend: "local", path: repositoryPath, isExistingRepository: false }, + }, + }); + expect(repositoryResponse.ok()).toBe(true); + const repository = (await repositoryResponse.json()) as { repository: { shortId: string } }; + + const notificationResponse = await page.request.post("/api/v1/notifications/destinations", { + data: { + name: `Notify-${runId}`, + config: { + type: "generic", + url: `${receiverOrigin}/notifications`, + method: "POST", + contentType: "application/json", + headers: ["X-Zerobyte-E2E: notifications"], + useJson: true, + titleKey: "title", + messageKey: "message", + }, + }, + }); + expect(notificationResponse.ok()).toBe(true); + const notification = (await notificationResponse.json()) as { id: number }; + + const testNotificationResponse = await page.request.post( + `/api/v1/notifications/destinations/${notification.id}/test`, + ); + expect(testNotificationResponse.ok()).toBe(true); + await receiver.waitFor( + (request) => request.path === "/notifications" && request.body.includes("Zerobyte Test Notification"), + ); + + const scheduleResponse = await page.request.post("/api/v1/backups", { + data: { + name: `Backup-${runId}`, + volumeId: volume.shortId, + repositoryId: repository.repository.shortId, + enabled: false, + cronExpression: "", + includePaths: [], + excludePatterns: [], + excludeIfPresent: [], + includePatterns: [], + oneFileSystem: false, + backupWebhooks: { + pre: { + url: `${receiverOrigin}/backup/pre`, + headers: ["X-Zerobyte-E2E: pre"], + }, + post: { + url: `${receiverOrigin}/backup/post`, + headers: ["X-Zerobyte-E2E: post"], + }, + }, + maxRetries: 0, + retryDelay: 1, + }, + }); + expect(scheduleResponse.ok()).toBe(true); + const schedule = (await scheduleResponse.json()) as BackupSchedule; + + const assignmentResponse = await page.request.put(`/api/v1/backups/${schedule.shortId}/notifications`, { + data: { + assignments: [ + { + destinationId: notification.id, + notifyOnStart: true, + notifyOnSuccess: true, + notifyOnWarning: true, + notifyOnFailure: true, + }, + ], + }, + }); + expect(assignmentResponse.ok()).toBe(true); + + const runResponse = await page.request.post(`/api/v1/backups/${schedule.shortId}/run`); + expect(runResponse.ok()).toBe(true); + + await receiver.waitFor( + (request) => + request.path === "/notifications" && + request.body.includes(`Zerobyte Backup-${runId} started`) && + request.body.includes(`Volume-${runId}`), + ); + + const preWebhook = await receiver.waitFor((request) => request.path === "/backup/pre"); + expect(preWebhook.method).toBe("POST"); + expect(preWebhook.headers["x-zerobyte-e2e"]).toBe("pre"); + expect(preWebhook.json).toMatchObject({ + phase: "pre", + event: "backup.pre", + scheduleId: schedule.shortId, + sourcePath, + }); + + const postWebhook = await receiver.waitFor((request) => request.path === "/backup/post"); + expect(postWebhook.method).toBe("POST"); + expect(postWebhook.headers["x-zerobyte-e2e"]).toBe("post"); + expect(postWebhook.json).toMatchObject({ + phase: "post", + event: "backup.post", + scheduleId: schedule.shortId, + sourcePath, + status: "success", + }); + + await receiver.waitFor( + (request) => + request.path === "/notifications" && + request.body.includes(`Zerobyte Backup-${runId} completed successfully`) && + request.body.includes(`Repo-${runId}`), + ); + + await expect(async () => { + const latestScheduleResponse = await page.request.get(`/api/v1/backups/${schedule.shortId}`); + expect(latestScheduleResponse.ok()).toBe(true); + const latestSchedule = (await latestScheduleResponse.json()) as BackupSchedule; + expect(latestSchedule.lastBackupStatus).toBe("success"); + expect(latestSchedule.lastBackupError).toBeNull(); + }).toPass({ timeout: 30000 }); + } finally { + await receiver.close(); + } +}); diff --git a/e2e/helpers/webhook-receiver.ts b/e2e/helpers/webhook-receiver.ts new file mode 100644 index 00000000..d07008c4 --- /dev/null +++ b/e2e/helpers/webhook-receiver.ts @@ -0,0 +1,57 @@ +import { createServer, type IncomingHttpHeaders } from "node:http"; +import { expect } from "@playwright/test"; + +export type WebhookReceiverRequest = { + method: string; + path: string; + headers: IncomingHttpHeaders; + body: string; + json: unknown; +}; + +export async function startWebhookReceiver(port: number) { + const requests: WebhookReceiverRequest[] = []; + + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk); + const body = Buffer.concat(chunks).toString("utf8"); + + requests.push({ + method: request.method ?? "", + path: new URL(request.url ?? "/", "http://receiver.test").pathname, + headers: request.headers, + body, + json: JSON.parse(body), + }); + + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "0.0.0.0", () => { + server.off("error", reject); + resolve(); + }); + }); + + return { + waitFor: async (predicate: (request: WebhookReceiverRequest) => boolean) => { + await expect + .poll(() => requests.some(predicate), { + timeout: 30000, + message: `Received webhook requests: ${JSON.stringify(requests, null, 2)}`, + }) + .toBe(true); + + return requests.find(predicate)!; + }, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +}