test: fix webhook mocks
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled

This commit is contained in:
Nicolas Meienberger 2026-05-29 20:06:44 +02:00
parent 6434068976
commit 8c6fc6cfed
No known key found for this signature in database
3 changed files with 265 additions and 190 deletions

View file

@ -1,7 +1,6 @@
import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Effect } from "effect"; import { Effect } from "effect";
import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect"; import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn"; import { fromPartial } from "@total-typescript/shoehorn";
import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol"; import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
@ -10,10 +9,54 @@ import { handleBackupCancelCommand } from "../backup-cancel";
import { handleBackupRunCommand } from "../backup-run"; import { handleBackupRunCommand } from "../backup-run";
import type { ControllerCommandContext, RunningJob } from "../../context"; import type { ControllerCommandContext, RunningJob } from "../../context";
const server = setupServer(); type WebhookHandler = (context: {
request: IncomingMessage;
response: ServerResponse;
body: string;
}) => void | Promise<void>;
beforeAll(() => { let webhookServer: Server;
server.listen({ onUnhandledRequest: "error" }); let webhookOrigin = "";
let webhookHandlers = new Map<string, WebhookHandler>();
const webhookUrl = (path: string) => `${webhookOrigin}${path}`;
const webhookRoute = (path: string, handler: WebhookHandler) => [`POST ${path}`, handler] as const;
const useWebhookHandlers = (...handlers: ReturnType<typeof webhookRoute>[]) => {
webhookHandlers = new Map(handlers);
};
const sendWebhookResponse = (response: ServerResponse, status = 204, body = "") => {
response.writeHead(status);
response.end(body);
};
beforeEach(async () => {
webhookHandlers = new Map();
webhookServer = nodeHttp.createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks).toString("utf8");
const pathname = new URL(request.url ?? "/", webhookOrigin).pathname;
const handler = webhookHandlers.get(`${request.method ?? ""} ${pathname}`);
if (!handler) {
sendWebhookResponse(response, 404);
return;
}
await handler({ request, response, body });
});
await new Promise<void>((resolve) => {
webhookServer.listen(0, "127.0.0.1", resolve);
});
const address = webhookServer.address();
if (!address || typeof address === "string") {
throw new Error("Failed to bind test webhook server");
}
webhookOrigin = `http://127.0.0.1:${address.port}`;
}); });
const createDeferred = <T>() => { const createDeferred = <T>() => {
@ -25,13 +68,14 @@ const createDeferred = <T>() => {
return { promise, resolve }; return { promise, resolve };
}; };
afterEach(() => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
server.resetHandlers(); webhookServer.closeAllConnections?.();
}); if (webhookServer.listening) {
await new Promise<void>((resolve, reject) => {
afterAll(() => { webhookServer.close((error) => (error ? reject(error) : resolve()));
server.close(); });
}
}); });
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) => const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
@ -71,7 +115,7 @@ const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
password: "password", password: "password",
}, },
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: ["http://localhost:8080"], webhookAllowedOrigins: [webhookOrigin],
webhookTimeoutMs: 60_000, webhookTimeoutMs: 60_000,
...overrides, ...overrides,
}); });
@ -114,16 +158,14 @@ const runBackupCommand = async (payload: BackupRunPayload) => {
test("runs pre and post backup webhooks around restic", async () => { test("runs pre and post backup webhooks around restic", async () => {
const events: string[] = []; const events: string[] = [];
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", async ({ request }) => { webhookRoute("/pre", ({ body, response }) => {
const body = (await request.json()) as { event: string }; events.push((JSON.parse(body) as { event: string }).event);
events.push(body.event); sendWebhookResponse(response);
return new HttpResponse(null, { status: 204 });
}), }),
http.post("http://localhost:8080/post", async ({ request }) => { webhookRoute("/post", ({ body, response }) => {
const body = (await request.json()) as { event: string }; events.push((JSON.parse(body) as { event: string }).event);
events.push(body.event); sendWebhookResponse(response);
return new HttpResponse(null, { status: 204 });
}), }),
); );
@ -140,8 +182,8 @@ test("runs pre and post backup webhooks around restic", async () => {
const messages = await runBackupCommand( const messages = await runBackupCommand(
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
}), }),
); );
@ -153,22 +195,22 @@ test("runs pre and post backup webhooks around restic", async () => {
test("sends configured webhook headers and body without changing them", async () => { test("sends configured webhook headers and body without changing them", async () => {
const requests: Array<{ url: string; headers: Headers; body: string }> = []; const requests: Array<{ url: string; headers: Headers; body: string }> = [];
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", async ({ request }) => { webhookRoute("/pre", ({ request, response, body }) => {
requests.push({ requests.push({
url: request.url, url: webhookUrl("/pre"),
headers: request.headers, headers: new Headers(request.headers as Record<string, string>),
body: await request.text(), body,
}); });
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
http.post("http://localhost:8080/post", async ({ request }) => { webhookRoute("/post", ({ request, response, body }) => {
requests.push({ requests.push({
url: request.url, url: webhookUrl("/post"),
headers: request.headers, headers: new Headers(request.headers as Record<string, string>),
body: await request.text(), body,
}); });
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
@ -182,12 +224,12 @@ test("sends configured webhook headers and body without changing them", async ()
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: { pre: {
url: "http://localhost:8080/pre", url: webhookUrl("/pre"),
headers: ["authorization: Bearer pre-token", "content-type: application/json"], headers: ["authorization: Bearer pre-token", "content-type: application/json"],
body: '{"action":"stop"}', body: '{"action":"stop"}',
}, },
post: { post: {
url: "http://localhost:8080/post", url: webhookUrl("/post"),
headers: ["authorization: Bearer post-token"], headers: ["authorization: Bearer post-token"],
body: "start-container", body: "start-container",
}, },
@ -196,20 +238,20 @@ test("sends configured webhook headers and body without changing them", async ()
); );
expect(requests).toHaveLength(2); expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe("http://localhost:8080/pre"); expect(requests[0]?.url).toBe(webhookUrl("/pre"));
expect(requests[0]?.headers.get("authorization")).toBe("Bearer pre-token"); expect(requests[0]?.headers.get("authorization")).toBe("Bearer pre-token");
expect(requests[0]?.headers.get("content-type")).toBe("application/json"); expect(requests[0]?.headers.get("content-type")).toBe("application/json");
expect(requests[0]?.body).toBe('{"action":"stop"}'); expect(requests[0]?.body).toBe('{"action":"stop"}');
expect(requests[1]?.url).toBe("http://localhost:8080/post"); expect(requests[1]?.url).toBe(webhookUrl("/post"));
expect(requests[1]?.headers.get("authorization")).toBe("Bearer post-token"); expect(requests[1]?.headers.get("authorization")).toBe("Bearer post-token");
expect(requests[1]?.body).toBe("start-container"); expect(requests[1]?.body).toBe("start-container");
}); });
test("fails without running restic when the pre-backup webhook fails", async () => { test("fails without running restic when the pre-backup webhook fails", async () => {
const backupMock = vi.fn(); const backupMock = vi.fn();
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", () => { webhookRoute("/pre", ({ response }) => {
return new HttpResponse("stop failed", { status: 500 }); sendWebhookResponse(response, 500, "stop failed");
}), }),
); );
vi.spyOn(resticServer, "createRestic").mockReturnValue( vi.spyOn(resticServer, "createRestic").mockReturnValue(
@ -221,7 +263,7 @@ test("fails without running restic when the pre-backup webhook fails", async ()
const messages = await runBackupCommand( const messages = await runBackupCommand(
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: null, post: null,
}, },
}), }),
@ -236,9 +278,9 @@ test("fails without running restic when the pre-backup webhook fails", async ()
}); });
test("reports a post-backup webhook failure as completed warning details", async () => { test("reports a post-backup webhook failure as completed warning details", async () => {
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", () => { webhookRoute("/post", ({ response }) => {
return new HttpResponse("start failed", { status: 500 }); sendWebhookResponse(response, 500, "start failed");
}), }),
); );
vi.spyOn(resticServer, "createRestic").mockReturnValue( vi.spyOn(resticServer, "createRestic").mockReturnValue(
@ -251,7 +293,7 @@ test("reports a post-backup webhook failure as completed warning details", async
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
}), }),
); );
@ -264,9 +306,9 @@ test("reports a post-backup webhook failure as completed warning details", async
}); });
test("includes post-backup webhook failure details when a backup is cancelled", async () => { test("includes post-backup webhook failure details when a backup is cancelled", async () => {
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", () => { webhookRoute("/post", ({ response }) => {
return new HttpResponse("start failed", { status: 500 }); sendWebhookResponse(response, 500, "start failed");
}), }),
); );
vi.spyOn(resticServer, "createRestic").mockReturnValue( vi.spyOn(resticServer, "createRestic").mockReturnValue(
@ -284,7 +326,7 @@ test("includes post-backup webhook failure details when a backup is cancelled",
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
}), }),
); );

View file

@ -1,22 +1,66 @@
import nodeHttp from "node:http"; import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Effect } from "effect"; import { Effect } from "effect";
import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, expect, test } from "vitest";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js"; import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js";
const server = setupServer(); type WebhookHandler = (context: {
request: IncomingMessage;
response: ServerResponse;
body: string;
}) => void | Promise<void>;
beforeAll(() => { let webhookServer: Server;
server.listen({ onUnhandledRequest: "error" }); let webhookOrigin = "";
let webhookHandlers = new Map<string, WebhookHandler>();
const routeKey = (method: string, path: string) => `${method} ${path}`;
const webhookUrl = (path: string) => `${webhookOrigin}${path}`;
const postWebhook = (path: string, handler: WebhookHandler) => ({ key: routeKey("POST", path), handler });
const useWebhookHandlers = (...handlers: ReturnType<typeof postWebhook>[]) => {
webhookHandlers = new Map(handlers.map(({ key, handler }) => [key, handler]));
};
const sendWebhookResponse = (response: ServerResponse, status = 204, body = "") => {
response.writeHead(status);
response.end(body);
};
beforeEach(async () => {
webhookHandlers = new Map();
webhookServer = nodeHttp.createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks).toString("utf8");
const pathname = new URL(request.url ?? "/", webhookOrigin).pathname;
const handler = webhookHandlers.get(routeKey(request.method ?? "", pathname));
if (!handler) {
sendWebhookResponse(response, 404);
return;
}
await handler({ request, response, body });
});
await new Promise<void>((resolve) => {
webhookServer.listen(0, "127.0.0.1", resolve);
});
const address = webhookServer.address();
if (!address || typeof address === "string") {
throw new Error("Failed to bind test webhook server");
}
webhookOrigin = `http://127.0.0.1:${address.port}`;
}); });
afterEach(() => { afterEach(async () => {
server.resetHandlers(); webhookServer.closeAllConnections?.();
}); if (webhookServer.listening) {
await new Promise<void>((resolve, reject) => {
afterAll(() => { webhookServer.close((error) => (error ? reject(error) : resolve()));
server.close(); });
}
}); });
const metadata = { const metadata = {
@ -45,7 +89,7 @@ const runWithHooks = <TResult>(
repositoryConfig: { backend: "local", path: "/tmp/repository" }, repositoryConfig: { backend: "local", path: "/tmp/repository" },
options: {}, options: {},
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: ["http://localhost:8080"], webhookAllowedOrigins: [webhookOrigin],
webhookTimeoutMs: 60_000, webhookTimeoutMs: 60_000,
signal: defaultSignal(), signal: defaultSignal(),
...options, ...options,
@ -69,23 +113,23 @@ test("runs pre and post webhooks around a successful backup", async () => {
let preBody: WebhookBody | undefined; let preBody: WebhookBody | undefined;
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", async ({ request }) => { postWebhook("/pre", ({ body, response }) => {
events.push("pre"); events.push("pre");
preBody = (await request.json()) as WebhookBody; preBody = JSON.parse(body) as WebhookBody;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
events.push("post"); events.push("post");
postBody = (await request.json()) as WebhookBody; postBody = JSON.parse(body) as WebhookBody;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
runBackup: () => runBackup: () =>
Effect.sync(() => { Effect.sync(() => {
@ -103,17 +147,17 @@ test("runs pre and post webhooks around a successful backup", async () => {
test("sends warning details to the post-backup webhook for a non-zero completed backup", async () => { test("sends warning details to the post-backup webhook for a non-zero completed backup", async () => {
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as WebhookBody; postBody = JSON.parse(body) as WebhookBody;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"), runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"),
}); });
@ -130,17 +174,17 @@ test("sends warning details to the post-backup webhook for a non-zero completed
test("sends warning details to the post-backup webhook for a zero-exit completed backup with warnings", async () => { test("sends warning details to the post-backup webhook for a zero-exit completed backup with warnings", async () => {
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as WebhookBody; postBody = JSON.parse(body) as WebhookBody;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
runBackup: () => completedBackup("snapshot-1", 0, "Backup was stopped by the user"), runBackup: () => completedBackup("snapshot-1", 0, "Backup was stopped by the user"),
}); });
@ -157,17 +201,17 @@ test("sends warning details to the post-backup webhook for a zero-exit completed
test("sends error details to the post-backup webhook when the backup fails", async () => { test("sends error details to the post-backup webhook when the backup fails", async () => {
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as WebhookBody; postBody = JSON.parse(body) as WebhookBody;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
runBackup: () => Effect.fail(new Error("restic failed")), runBackup: () => Effect.fail(new Error("restic failed")),
}); });
@ -180,20 +224,20 @@ test("fails without running the backup or post webhook when the pre-backup webho
let backupRan = false; let backupRan = false;
let postRan = false; let postRan = false;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", () => { postWebhook("/pre", ({ response }) => {
return new HttpResponse("stop failed", { status: 500 }); sendWebhookResponse(response, 500, "stop failed");
}), }),
http.post("http://localhost:8080/post", () => { postWebhook("/post", ({ response }) => {
postRan = true; postRan = true;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
runBackup: () => runBackup: () =>
Effect.sync(() => { Effect.sync(() => {
@ -216,12 +260,16 @@ test("sends configured webhook headers and body without replacing them", async (
let authorization: string | null | undefined; let authorization: string | null | undefined;
let contentType: string | null | undefined; let contentType: string | null | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ request, response, body: requestBody }) => {
body = await request.text(); body = requestBody;
authorization = request.headers.get("authorization"); authorization = Array.isArray(request.headers.authorization)
contentType = request.headers.get("content-type"); ? request.headers.authorization.join(", ")
return new HttpResponse(null, { status: 204 }); : request.headers.authorization;
contentType = Array.isArray(request.headers["content-type"])
? request.headers["content-type"].join(", ")
: (request.headers["content-type"] ?? null);
sendWebhookResponse(response);
}), }),
); );
@ -229,7 +277,7 @@ test("sends configured webhook headers and body without replacing them", async (
webhooks: { webhooks: {
pre: null, pre: null,
post: { post: {
url: "http://localhost:8080/post", url: webhookUrl("/post"),
headers: ["authorization: Bearer post-token"], headers: ["authorization: Bearer post-token"],
body: "start-container", body: "start-container",
}, },
@ -246,17 +294,17 @@ test("runs the post-backup webhook after cancellation without using the cancelle
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as { status?: string; error?: string }; postBody = JSON.parse(body) as { status?: string; error?: string };
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -274,17 +322,17 @@ test("runs the post-backup webhook when cancellation returns a completed backup
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as { status?: string; error?: string }; postBody = JSON.parse(body) as { status?: string; error?: string };
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -302,17 +350,17 @@ test("includes post-backup webhook failure details after cancellation", async ()
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as { status?: string; error?: string }; postBody = JSON.parse(body) as { status?: string; error?: string };
return new HttpResponse("start failed", { status: 500 }); sendWebhookResponse(response, 500, "start failed");
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -335,17 +383,17 @@ test("includes post-backup webhook failure details after completed cancellation"
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/post", async ({ request }) => { postWebhook("/post", ({ body, response }) => {
postBody = (await request.json()) as { status?: string; error?: string }; postBody = JSON.parse(body) as { status?: string; error?: string };
return new HttpResponse("cleanup failed", { status: 500 }); sendWebhookResponse(response, 500, "cleanup failed");
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -369,7 +417,7 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://127.0.0.1:8080/pre" }, pre: { url: "http://127.0.0.1:9/pre" },
post: null, post: null,
}, },
runBackup: () => runBackup: () =>
@ -382,23 +430,23 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
expect(backupRan).toBe(false); expect(backupRan).toBe(false);
expect(result).toEqual({ expect(result).toEqual({
status: "failed", status: "failed",
error: "pre webhook URL origin is not allowed. Add http://127.0.0.1:8080 to WEBHOOK_ALLOWED_ORIGINS.", error: "pre webhook URL origin is not allowed. Add http://127.0.0.1:9 to WEBHOOK_ALLOWED_ORIGINS.",
}); });
}); });
test("matches configured webhook origins with trailing slashes or paths", async () => { test("matches configured webhook origins with trailing slashes or paths", async () => {
let backupRan = false; let backupRan = false;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", () => { postWebhook("/pre", ({ response }) => {
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhookAllowedOrigins: ["http://localhost:8080/", "http://example.com/webhook"], webhookAllowedOrigins: [`${webhookOrigin}/`, "http://example.com/webhook"],
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: null, post: null,
}, },
runBackup: () => runBackup: () =>
@ -415,19 +463,20 @@ test("matches configured webhook origins with trailing slashes or paths", async
test("does not follow webhook redirects", async () => { test("does not follow webhook redirects", async () => {
let redirectedTargetCalled = false; let redirectedTargetCalled = false;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/redirect", () => { postWebhook("/redirect", ({ response }) => {
return new HttpResponse(null, { status: 302, headers: { location: "http://localhost:8080/target" } }); response.writeHead(302, { location: webhookUrl("/target") });
response.end();
}), }),
http.post("http://localhost:8080/target", () => { postWebhook("/target", ({ response }) => {
redirectedTargetCalled = true; redirectedTargetCalled = true;
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/redirect" }, pre: { url: webhookUrl("/redirect") },
post: null, post: null,
}, },
runBackup: () => completedBackup(null), runBackup: () => completedBackup(null),
@ -438,56 +487,36 @@ test("does not follow webhook redirects", async () => {
}); });
test("uses the configured webhook timeout for the request", async () => { test("uses the configured webhook timeout for the request", async () => {
server.close(); useWebhookHandlers(
postWebhook("/pre", ({ response }) => {
setTimeout(() => {
if (response.destroyed) return;
const webhookServer = nodeHttp.createServer((_request, response) => { response.writeHead(204);
setTimeout(() => { response.end();
response.writeHead(204); }, 300);
response.end(); }),
}, 300); );
const result = await runWithHooks({
webhookTimeoutMs: 50,
webhooks: {
pre: { url: webhookUrl("/pre") },
post: null,
},
runBackup: () => completedBackup(null),
}); });
try { expect(result.status).toBe("failed");
await new Promise<void>((resolve) => { if (result.status === "failed") {
webhookServer.listen(0, "127.0.0.1", resolve); expect(result.error).toContain("pre webhook failed: Webhook timed out");
});
const address = webhookServer.address();
if (!address || typeof address === "string") {
throw new Error("Failed to bind test webhook server");
}
const origin = `http://127.0.0.1:${address.port}`;
const result = await runWithHooks({
webhookAllowedOrigins: [origin],
webhookTimeoutMs: 50,
webhooks: {
pre: { url: `${origin}/pre` },
post: null,
},
runBackup: () => completedBackup(null),
});
expect(result.status).toBe("failed");
if (result.status === "failed") {
expect(result.error).toContain("pre webhook failed: Webhook timed out");
}
} finally {
webhookServer.closeAllConnections?.();
if (webhookServer.listening) {
await new Promise<void>((resolve, reject) => {
webhookServer.close((error) => (error ? reject(error) : resolve()));
});
}
server.listen({ onUnhandledRequest: "error" });
} }
}); });
test("rejects oversized webhook request bodies and headers", async () => { test("rejects oversized webhook request bodies and headers", async () => {
const bodyResult = await runWithHooks({ const bodyResult = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre", body: "a".repeat(64 * 1024 + 1) }, pre: { url: webhookUrl("/pre"), body: "a".repeat(64 * 1024 + 1) },
post: null, post: null,
}, },
runBackup: () => completedBackup(null), runBackup: () => completedBackup(null),
@ -497,7 +526,7 @@ test("rejects oversized webhook request bodies and headers", async () => {
const headersResult = await runWithHooks({ const headersResult = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre", headers: [`x-large: ${"a".repeat(8 * 1024)}`] }, pre: { url: webhookUrl("/pre"), headers: [`x-large: ${"a".repeat(8 * 1024)}`] },
post: null, post: null,
}, },
runBackup: () => completedBackup(null), runBackup: () => completedBackup(null),
@ -507,13 +536,13 @@ test("rejects oversized webhook request bodies and headers", async () => {
}); });
test("rejects malformed webhook header lines", () => { test("rejects malformed webhook header lines", () => {
expect(() => backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Malformed"] })).toThrow( expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/pre"), headers: ["Malformed"] })).toThrow(
"Headers must use non-empty Key: Value format with valid header names", "Headers must use non-empty Key: Value format with valid header names",
); );
expect(() => expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/pre"), headers: ["Bad Header: value"] })).toThrow(
backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Bad Header: value"] }), "Headers must use non-empty Key: Value format with valid header names",
).toThrow("Headers must use non-empty Key: Value format with valid header names"); );
}); });
test("cancels before the pre-backup webhook without running the backup", async () => { test("cancels before the pre-backup webhook without running the backup", async () => {
@ -524,8 +553,8 @@ test("cancels before the pre-backup webhook without running the backup", async (
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: { url: "http://localhost:8080/post" }, post: { url: webhookUrl("/post") },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -543,16 +572,16 @@ test("cancels after the pre-backup webhook without running the backup", async ()
const abortController = new AbortController(); const abortController = new AbortController();
let backupRan = false; let backupRan = false;
server.use( useWebhookHandlers(
http.post("http://localhost:8080/pre", () => { postWebhook("/pre", ({ response }) => {
abortController.abort(new Error("Backup was cancelled")); abortController.abort(new Error("Backup was cancelled"));
return new HttpResponse(null, { status: 204 }); sendWebhookResponse(response);
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://localhost:8080/pre" }, pre: { url: webhookUrl("/pre") },
post: null, post: null,
}, },
signal: abortController.signal, signal: abortController.signal,

View file

@ -249,11 +249,15 @@ const sendWebhookRequest = (
}); });
const abortRequest = () => { const abortRequest = () => {
req.destroy(signal.reason || new Error("Operation aborted")); const error = signal.reason || new Error("Operation aborted");
settle(() => reject(error));
req.destroy(error);
}; };
req.setTimeout(timeoutMs, () => { req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Webhook timed out after ${Math.round(timeoutMs / 1000)} seconds`)); const error = new Error(`Webhook timed out after ${Math.round(timeoutMs / 1000)} seconds`);
settle(() => reject(error));
req.destroy(error);
}); });
req.on("error", (error) => settle(() => reject(error))); req.on("error", (error) => settle(() => reject(error)));