test(ingest): webhook and cron endpoint protection
This commit is contained in:
parent
8c60c1dc1b
commit
cd9c8df031
1 changed files with 105 additions and 0 deletions
105
tests/integration/ingest.test.ts
Normal file
105
tests/integration/ingest.test.ts
Normal file
|
|
@ -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: "" });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue