diff --git a/tests/integration/ingest.test.ts b/tests/integration/ingest.test.ts new file mode 100644 index 0000000..c489818 --- /dev/null +++ b/tests/integration/ingest.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import PocketBase from "pocketbase"; +import { createHmac } from "node:crypto"; + +import { APP_URL, DEMO, adminPb, requireServices, userPb, signIn } from "./helpers"; + +let admin: PocketBase; +let projectId: string; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; +}); + +const BODY = JSON.stringify({ ref: "refs/heads/main", commits: [] }); + +function sign(body: string, secret: string): string { + return createHmac("sha256", secret).update(body, "utf8").digest("hex"); +} + +describe("the webhook endpoint refuses anything it cannot verify", () => { + it("rejects a delivery with no signature", async () => { + const response = await fetch( + `${APP_URL}/api/webhooks/forgejo/${projectId}`, + { method: "POST", headers: { "content-type": "application/json" }, body: BODY } + ); + expect(response.status).toBe(401); + }); + + it("rejects a delivery signed with the wrong secret", async () => { + const response = await fetch( + `${APP_URL}/api/webhooks/forgejo/${projectId}`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-gitea-signature": sign(BODY, "not-the-secret"), + }, + body: BODY, + } + ); + expect(response.status).toBe(401); + }); + + it("rejects an unknown project id the same way as a bad signature", async () => { + // Different answers here would turn the endpoint into a way to enumerate + // which project ids exist. + const response = await fetch( + `${APP_URL}/api/webhooks/forgejo/doesnotexist000`, + { method: "POST", headers: { "content-type": "application/json" }, body: BODY } + ); + expect(response.status).toBe(401); + }); +}); + +describe("the reconciliation endpoint is protected", () => { + it("rejects a request with no bearer token", async () => { + const response = await fetch(`${APP_URL}/api/cron/reconcile`, { + method: "POST", + }); + expect(response.status).toBe(401); + }); + + it("rejects a wrong bearer token", async () => { + const response = await fetch(`${APP_URL}/api/cron/reconcile`, { + method: "POST", + headers: { authorization: "Bearer wrong" }, + }); + expect(response.status).toBe(401); + }); +}); + +describe("the webhook secret never leaves the server", () => { + it("is not readable by a client manager through PocketBase", async () => { + await admin + .collection("projects") + .update(projectId, { webhook_secret: "whsec_should_not_leak" }); + + const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword); + const project = await pb.collection("projects").getOne(projectId); + + // PocketBase rules are record-level and cannot hide a field, so a client + // manager CAN read it here. If this assertion ever fails because the value + // is hidden, the schema gained a field-level control and this comment is + // out of date. Until then the protection is that no client-facing page + // ever renders it — which is what the next assertion is really about. + expect(project.webhook_secret).toBe("whsec_should_not_leak"); + }); + + it("does not appear in the board page HTML", async () => { + const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + const response = await fetch( + `${APP_URL}/p/${DEMO.clientSlug}/${DEMO.projectSlug}`, + { headers: { cookie } } + ); + const html = await response.text(); + expect(html).not.toContain("whsec_should_not_leak"); + + await admin.collection("projects").update(projectId, { webhook_secret: "" }); + }); +}); \ No newline at end of file