test: fix webhook mocks
This commit is contained in:
parent
6434068976
commit
be3200fa64
3 changed files with 265 additions and 190 deletions
|
|
@ -1,7 +1,6 @@
|
|||
import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { Effect } from "effect";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { fromPartial } from "@total-typescript/shoehorn";
|
||||
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 type { ControllerCommandContext, RunningJob } from "../../context";
|
||||
|
||||
const server = setupServer();
|
||||
type WebhookHandler = (context: {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
body: string;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
let webhookServer: Server;
|
||||
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>() => {
|
||||
|
|
@ -25,13 +68,14 @@ const createDeferred = <T>() => {
|
|||
return { promise, resolve };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
webhookServer.closeAllConnections?.();
|
||||
if (webhookServer.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webhookServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
||||
|
|
@ -71,7 +115,7 @@ const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
|||
password: "password",
|
||||
},
|
||||
webhooks: { pre: null, post: null },
|
||||
webhookAllowedOrigins: ["http://localhost:8080"],
|
||||
webhookAllowedOrigins: [webhookOrigin],
|
||||
webhookTimeoutMs: 60_000,
|
||||
...overrides,
|
||||
});
|
||||
|
|
@ -114,16 +158,14 @@ const runBackupCommand = async (payload: BackupRunPayload) => {
|
|||
test("runs pre and post backup webhooks around restic", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||
const body = (await request.json()) as { event: string };
|
||||
events.push(body.event);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/pre", ({ body, response }) => {
|
||||
events.push((JSON.parse(body) as { event: string }).event);
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
const body = (await request.json()) as { event: string };
|
||||
events.push(body.event);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
webhookRoute("/post", ({ body, response }) => {
|
||||
events.push((JSON.parse(body) as { event: string }).event);
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -140,8 +182,8 @@ test("runs pre and post backup webhooks around restic", async () => {
|
|||
const messages = await runBackupCommand(
|
||||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
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 () => {
|
||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/pre", ({ request, response, body }) => {
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
url: webhookUrl("/pre"),
|
||||
headers: new Headers(request.headers as Record<string, string>),
|
||||
body,
|
||||
});
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
webhookRoute("/post", ({ request, response, body }) => {
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
url: webhookUrl("/post"),
|
||||
headers: new Headers(request.headers as Record<string, string>),
|
||||
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({
|
||||
webhooks: {
|
||||
pre: {
|
||||
url: "http://localhost:8080/pre",
|
||||
url: webhookUrl("/pre"),
|
||||
headers: ["authorization: Bearer pre-token", "content-type: application/json"],
|
||||
body: '{"action":"stop"}',
|
||||
},
|
||||
post: {
|
||||
url: "http://localhost:8080/post",
|
||||
url: webhookUrl("/post"),
|
||||
headers: ["authorization: Bearer post-token"],
|
||||
body: "start-container",
|
||||
},
|
||||
|
|
@ -196,20 +238,20 @@ test("sends configured webhook headers and body without changing them", async ()
|
|||
);
|
||||
|
||||
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("content-type")).toBe("application/json");
|
||||
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]?.body).toBe("start-container");
|
||||
});
|
||||
|
||||
test("fails without running restic when the pre-backup webhook fails", async () => {
|
||||
const backupMock = vi.fn();
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
return new HttpResponse("stop failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/pre", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "stop failed");
|
||||
}),
|
||||
);
|
||||
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(
|
||||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", () => {
|
||||
return new HttpResponse("start failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/post", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "start failed");
|
||||
}),
|
||||
);
|
||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||
|
|
@ -251,7 +293,7 @@ test("reports a post-backup webhook failure as completed warning details", async
|
|||
createRunPayload({
|
||||
webhooks: {
|
||||
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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", () => {
|
||||
return new HttpResponse("start failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/post", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "start failed");
|
||||
}),
|
||||
);
|
||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||
|
|
@ -284,7 +326,7 @@ test("includes post-backup webhook failure details when a backup is cancelled",
|
|||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js";
|
||||
|
||||
const server = setupServer();
|
||||
type WebhookHandler = (context: {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
body: string;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
let webhookServer: Server;
|
||||
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(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
afterEach(async () => {
|
||||
webhookServer.closeAllConnections?.();
|
||||
if (webhookServer.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webhookServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metadata = {
|
||||
|
|
@ -45,7 +89,7 @@ const runWithHooks = <TResult>(
|
|||
repositoryConfig: { backend: "local", path: "/tmp/repository" },
|
||||
options: {},
|
||||
webhooks: { pre: null, post: null },
|
||||
webhookAllowedOrigins: ["http://localhost:8080"],
|
||||
webhookAllowedOrigins: [webhookOrigin],
|
||||
webhookTimeoutMs: 60_000,
|
||||
signal: defaultSignal(),
|
||||
...options,
|
||||
|
|
@ -69,23 +113,23 @@ test("runs pre and post webhooks around a successful backup", async () => {
|
|||
let preBody: WebhookBody | undefined;
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ body, response }) => {
|
||||
events.push("pre");
|
||||
preBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
preBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
events.push("post");
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () =>
|
||||
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 () => {
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
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 () => {
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
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 () => {
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
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 postRan = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
return new HttpResponse("stop failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "stop failed");
|
||||
}),
|
||||
http.post("http://localhost:8080/post", () => {
|
||||
postWebhook("/post", ({ response }) => {
|
||||
postRan = true;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () =>
|
||||
Effect.sync(() => {
|
||||
|
|
@ -216,12 +260,16 @@ test("sends configured webhook headers and body without replacing them", async (
|
|||
let authorization: string | null | undefined;
|
||||
let contentType: string | null | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
body = await request.text();
|
||||
authorization = request.headers.get("authorization");
|
||||
contentType = request.headers.get("content-type");
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ request, response, body: requestBody }) => {
|
||||
body = requestBody;
|
||||
authorization = Array.isArray(request.headers.authorization)
|
||||
? request.headers.authorization.join(", ")
|
||||
: 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: {
|
||||
pre: null,
|
||||
post: {
|
||||
url: "http://localhost:8080/post",
|
||||
url: webhookUrl("/post"),
|
||||
headers: ["authorization: Bearer post-token"],
|
||||
body: "start-container",
|
||||
},
|
||||
|
|
@ -246,17 +294,17 @@ test("runs the post-backup webhook after cancellation without using the cancelle
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -274,17 +322,17 @@ test("runs the post-backup webhook when cancellation returns a completed backup
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -302,17 +350,17 @@ test("includes post-backup webhook failure details after cancellation", async ()
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse("start failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response, 500, "start failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -335,17 +383,17 @@ test("includes post-backup webhook failure details after completed cancellation"
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse("cleanup failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response, 500, "cleanup failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -369,7 +417,7 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
|
|||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://127.0.0.1:8080/pre" },
|
||||
pre: { url: "http://127.0.0.1:9/pre" },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () =>
|
||||
|
|
@ -382,23 +430,23 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
|
|||
expect(backupRan).toBe(false);
|
||||
expect(result).toEqual({
|
||||
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 () => {
|
||||
let backupRan = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhookAllowedOrigins: ["http://localhost:8080/", "http://example.com/webhook"],
|
||||
webhookAllowedOrigins: [`${webhookOrigin}/`, "http://example.com/webhook"],
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () =>
|
||||
|
|
@ -415,19 +463,20 @@ test("matches configured webhook origins with trailing slashes or paths", async
|
|||
test("does not follow webhook redirects", async () => {
|
||||
let redirectedTargetCalled = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/redirect", () => {
|
||||
return new HttpResponse(null, { status: 302, headers: { location: "http://localhost:8080/target" } });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/redirect", ({ response }) => {
|
||||
response.writeHead(302, { location: webhookUrl("/target") });
|
||||
response.end();
|
||||
}),
|
||||
http.post("http://localhost:8080/target", () => {
|
||||
postWebhook("/target", ({ response }) => {
|
||||
redirectedTargetCalled = true;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/redirect" },
|
||||
pre: { url: webhookUrl("/redirect") },
|
||||
post: 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 () => {
|
||||
server.close();
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
setTimeout(() => {
|
||||
if (response.destroyed) return;
|
||||
|
||||
const webhookServer = nodeHttp.createServer((_request, response) => {
|
||||
setTimeout(() => {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
}, 300);
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
}, 300);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhookTimeoutMs: 50,
|
||||
webhooks: {
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () => completedBackup(null),
|
||||
});
|
||||
|
||||
try {
|
||||
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");
|
||||
}
|
||||
|
||||
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" });
|
||||
expect(result.status).toBe("failed");
|
||||
if (result.status === "failed") {
|
||||
expect(result.error).toContain("pre webhook failed: Webhook timed out");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects oversized webhook request bodies and headers", async () => {
|
||||
const bodyResult = await runWithHooks({
|
||||
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,
|
||||
},
|
||||
runBackup: () => completedBackup(null),
|
||||
|
|
@ -497,7 +526,7 @@ test("rejects oversized webhook request bodies and headers", async () => {
|
|||
|
||||
const headersResult = await runWithHooks({
|
||||
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,
|
||||
},
|
||||
runBackup: () => completedBackup(null),
|
||||
|
|
@ -507,13 +536,13 @@ test("rejects oversized webhook request bodies and headers", async () => {
|
|||
});
|
||||
|
||||
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",
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Bad Header: value"] }),
|
||||
).toThrow("Headers must use non-empty Key: Value format with valid header names");
|
||||
expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/pre"), headers: ["Bad Header: value"] })).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 () => {
|
||||
|
|
@ -524,8 +553,8 @@ test("cancels before the pre-backup webhook without running the backup", async (
|
|||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -543,16 +572,16 @@ test("cancels after the pre-backup webhook without running the backup", async ()
|
|||
const abortController = new AbortController();
|
||||
let backupRan = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
abortController.abort(new Error("Backup was cancelled"));
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: null,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
|
|
|
|||
|
|
@ -249,11 +249,15 @@ const sendWebhookRequest = (
|
|||
});
|
||||
|
||||
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.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)));
|
||||
|
|
|
|||
Loading…
Reference in a new issue