test(e2e): add test suite to test webhooks (#922)
This commit is contained in:
parent
2f78c4a1fb
commit
008296238b
3 changed files with 230 additions and 0 deletions
|
|
@ -62,6 +62,7 @@ services:
|
||||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||||
- BASE_URL=http://localhost:4096
|
- BASE_URL=http://localhost:4096
|
||||||
- TRUSTED_ORIGINS=https://tinyauth.example.com:5557,https://localhost:5557
|
- 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
|
- NODE_EXTRA_CA_CERTS=/tinyauth-ca/caddy/pki/authorities/local/root.crt
|
||||||
- SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt
|
- SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt
|
||||||
devices:
|
devices:
|
||||||
|
|
|
||||||
172
e2e/0006-notifications-webhooks.spec.ts
Normal file
172
e2e/0006-notifications-webhooks.spec.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
57
e2e/helpers/webhook-receiver.ts
Normal file
57
e2e/helpers/webhook-receiver.ts
Normal file
|
|
@ -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<void>((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<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue