105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
|
|
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: "" });
|
||
|
|
});
|
||
|
|
});
|