This commit is contained in:
commit
0ba9b9f95c
17 changed files with 705 additions and 0 deletions
20
.forgejo/workflows/check.yml
Normal file
20
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: bun
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: bun run check:types
|
||||
- name: Test
|
||||
run: bun test
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
.env
|
||||
data/*.jsonl
|
||||
data/events
|
||||
data/jobs
|
||||
.DS_Store
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM oven/bun:1.3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "src/server.ts"]
|
||||
32
README.md
Normal file
32
README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# git-webhooks
|
||||
|
||||
Containerized Bun service for GitHub and jojo.build webhooks.
|
||||
|
||||
## Endpoints
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
POST /git-webhooks/jojo
|
||||
POST /git-webhooks/github
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
```text
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
DATA_DIR=/app/data
|
||||
JOJO_WEBHOOK_SECRET=...
|
||||
GITHUB_WEBHOOK_SECRET=...
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run check
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Accepted webhook events are appended to `DATA_DIR/events.jsonl`; queued work
|
||||
items are appended to `DATA_DIR/jobs.jsonl`.
|
||||
24
bun.lock
Normal file
24
bun.lock
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@peezy.tech/git-webhooks",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@peezy.tech/git-webhooks",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"start": "bun src/server.ts",
|
||||
"test": "bun test",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check": "bun run check:types && bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
23
src/http.ts
Normal file
23
src/http.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function textResponse(body: string, init: ResponseInit = {}): Response {
|
||||
return new Response(body, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function methodNotAllowed(): Response {
|
||||
return jsonResponse({ error: "method_not_allowed" }, { status: 405 });
|
||||
}
|
||||
75
src/providers/github.ts
Normal file
75
src/providers/github.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { GitWebhookEvent, NormalizedEventName } from "../types";
|
||||
|
||||
type GitHubPayload = {
|
||||
action?: string;
|
||||
zen?: string;
|
||||
hook_id?: number;
|
||||
ref?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
repository?: {
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
clone_url?: string;
|
||||
ssh_url?: string;
|
||||
default_branch?: string;
|
||||
owner?: {
|
||||
login?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
sender?: {
|
||||
login?: string;
|
||||
html_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeEventName(providerEvent: string, payload: GitHubPayload): NormalizedEventName {
|
||||
if (providerEvent === "ping" || payload.zen || payload.hook_id) return "ping";
|
||||
if (providerEvent === "push") return "push";
|
||||
if (providerEvent === "pull_request") return "pull_request";
|
||||
if (providerEvent === "workflow_run") return "workflow_run";
|
||||
if (providerEvent === "release") return "release";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function normalizeGithubEvent(input: {
|
||||
providerEvent: string;
|
||||
deliveryId: string;
|
||||
receivedAt: string;
|
||||
payload: GitHubPayload;
|
||||
}): GitWebhookEvent {
|
||||
const { payload } = input;
|
||||
const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.name;
|
||||
const repoName = payload.repository?.name;
|
||||
const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined);
|
||||
|
||||
return {
|
||||
provider: "github",
|
||||
event: normalizeEventName(input.providerEvent, payload),
|
||||
providerEvent: input.providerEvent,
|
||||
deliveryId: input.deliveryId,
|
||||
receivedAt: input.receivedAt,
|
||||
repo: repoOwner && repoName && fullName
|
||||
? {
|
||||
owner: repoOwner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
cloneUrl: payload.repository?.clone_url,
|
||||
sshUrl: payload.repository?.ssh_url,
|
||||
defaultBranch: payload.repository?.default_branch,
|
||||
}
|
||||
: undefined,
|
||||
sender: payload.sender?.login
|
||||
? {
|
||||
username: payload.sender.login,
|
||||
htmlUrl: payload.sender.html_url,
|
||||
}
|
||||
: undefined,
|
||||
ref: payload.ref,
|
||||
before: payload.before,
|
||||
after: payload.after,
|
||||
action: payload.action,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
76
src/providers/jojo.ts
Normal file
76
src/providers/jojo.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { GitWebhookEvent, NormalizedEventName } from "../types";
|
||||
|
||||
type JojoPayload = {
|
||||
action?: string;
|
||||
ref?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
repository?: {
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
clone_url?: string;
|
||||
ssh_url?: string;
|
||||
default_branch?: string;
|
||||
owner?: {
|
||||
login?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
sender?: {
|
||||
login?: string;
|
||||
username?: string;
|
||||
html_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeEventName(providerEvent: string): NormalizedEventName {
|
||||
if (providerEvent === "ping") return "ping";
|
||||
if (providerEvent === "push") return "push";
|
||||
if (providerEvent === "pull_request") return "pull_request";
|
||||
if (providerEvent === "workflow_run") return "workflow_run";
|
||||
if (providerEvent === "release") return "release";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function normalizeJojoEvent(input: {
|
||||
providerEvent: string;
|
||||
deliveryId: string;
|
||||
receivedAt: string;
|
||||
payload: JojoPayload;
|
||||
}): GitWebhookEvent {
|
||||
const { payload } = input;
|
||||
const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.username ?? payload.repository?.owner?.name;
|
||||
const repoName = payload.repository?.name;
|
||||
const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined);
|
||||
const senderUsername = payload.sender?.login ?? payload.sender?.username;
|
||||
|
||||
return {
|
||||
provider: "jojo",
|
||||
event: normalizeEventName(input.providerEvent),
|
||||
providerEvent: input.providerEvent,
|
||||
deliveryId: input.deliveryId,
|
||||
receivedAt: input.receivedAt,
|
||||
repo: repoOwner && repoName && fullName
|
||||
? {
|
||||
owner: repoOwner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
cloneUrl: payload.repository?.clone_url,
|
||||
sshUrl: payload.repository?.ssh_url,
|
||||
defaultBranch: payload.repository?.default_branch,
|
||||
}
|
||||
: undefined,
|
||||
sender: senderUsername
|
||||
? {
|
||||
username: senderUsername,
|
||||
htmlUrl: payload.sender?.html_url,
|
||||
}
|
||||
: undefined,
|
||||
ref: payload.ref,
|
||||
before: payload.before,
|
||||
after: payload.after,
|
||||
action: payload.action,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
43
src/queue.ts
Normal file
43
src/queue.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { GitWebhookEvent, QueuedJob } from "./types";
|
||||
|
||||
async function appendJsonLine(path: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, `${JSON.stringify(value)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export class EventStore {
|
||||
readonly eventsPath: string;
|
||||
readonly jobsPath: string;
|
||||
|
||||
constructor(dataDir: string) {
|
||||
this.eventsPath = join(dataDir, "events.jsonl");
|
||||
this.jobsPath = join(dataDir, "jobs.jsonl");
|
||||
}
|
||||
|
||||
async appendEvent(event: GitWebhookEvent): Promise<void> {
|
||||
await appendJsonLine(this.eventsPath, event);
|
||||
}
|
||||
|
||||
async appendJob(job: QueuedJob): Promise<void> {
|
||||
await appendJsonLine(this.jobsPath, job);
|
||||
}
|
||||
}
|
||||
|
||||
export function jobForEvent(event: GitWebhookEvent): QueuedJob | null {
|
||||
if (event.event !== "push" || event.ref !== "refs/heads/main" || !event.repo || !event.after) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${event.provider}:${event.deliveryId}:main_push`,
|
||||
kind: "main_push",
|
||||
provider: event.provider,
|
||||
repoFullName: event.repo.fullName,
|
||||
ref: event.ref,
|
||||
sha: event.after,
|
||||
deliveryId: event.deliveryId,
|
||||
createdAt: event.receivedAt,
|
||||
};
|
||||
}
|
||||
114
src/server.ts
Normal file
114
src/server.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
|
||||
import { normalizeGithubEvent } from "./providers/github";
|
||||
import { normalizeJojoEvent } from "./providers/jojo";
|
||||
import { EventStore, jobForEvent } from "./queue";
|
||||
import { verifyGithubSignature, verifyJojoSignature } from "./signatures";
|
||||
import type { GitWebhookEvent } from "./types";
|
||||
|
||||
const maxBodyBytes = 1024 * 1024;
|
||||
|
||||
export type ServerConfig = {
|
||||
githubSecret: string;
|
||||
jojoSecret: string;
|
||||
dataDir: string;
|
||||
};
|
||||
|
||||
function getHeader(headers: Headers, name: string, fallback: string): string {
|
||||
return headers.get(name) ?? fallback;
|
||||
}
|
||||
|
||||
async function parseJsonBody(request: Request): Promise<{ body: string; payload: unknown } | Response> {
|
||||
const body = await request.text();
|
||||
if (body.length > maxBodyBytes) {
|
||||
return jsonResponse({ error: "payload_too_large" }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
return { body, payload: JSON.parse(body) };
|
||||
} catch {
|
||||
return jsonResponse({ error: "invalid_json" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent): Promise<Response> {
|
||||
await store.appendEvent(event);
|
||||
const job = jobForEvent(event);
|
||||
if (job) {
|
||||
await store.appendJob(job);
|
||||
}
|
||||
console.log(JSON.stringify({ type: "webhook.accepted", provider: event.provider, event: event.event, deliveryId: event.deliveryId, job: job?.id }));
|
||||
return jsonResponse({ status: event.event === "ping" ? "ok" : "accepted", event: event.event, deliveryId: event.deliveryId }, {
|
||||
status: event.event === "ping" ? 200 : 202,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGithub(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
|
||||
if (request.method !== "POST") return methodNotAllowed();
|
||||
const parsed = await parseJsonBody(request);
|
||||
if (parsed instanceof Response) return parsed;
|
||||
|
||||
const verified = await verifyGithubSignature(config.githubSecret, parsed.body, request.headers.get("x-hub-signature-256"));
|
||||
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
|
||||
|
||||
const event = normalizeGithubEvent({
|
||||
providerEvent: getHeader(request.headers, "x-github-event", "unknown"),
|
||||
deliveryId: getHeader(request.headers, "x-github-delivery", randomUUID()),
|
||||
receivedAt: new Date().toISOString(),
|
||||
payload: parsed.payload as never,
|
||||
});
|
||||
return persistAcceptedEvent(store, event);
|
||||
}
|
||||
|
||||
async function handleJojo(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
|
||||
if (request.method !== "POST") return methodNotAllowed();
|
||||
const parsed = await parseJsonBody(request);
|
||||
if (parsed instanceof Response) return parsed;
|
||||
|
||||
const verified = await verifyJojoSignature(config.jojoSecret, parsed.body, request.headers);
|
||||
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
|
||||
|
||||
const event = normalizeJojoEvent({
|
||||
providerEvent: getHeader(request.headers, "x-forgejo-event", request.headers.get("x-gitea-event") ?? "unknown"),
|
||||
deliveryId: getHeader(request.headers, "x-forgejo-delivery", request.headers.get("x-gitea-delivery") ?? randomUUID()),
|
||||
receivedAt: new Date().toISOString(),
|
||||
payload: parsed.payload as never,
|
||||
});
|
||||
return persistAcceptedEvent(store, event);
|
||||
}
|
||||
|
||||
export function createHandler(config: ServerConfig): (request: Request) => Promise<Response> | Response {
|
||||
const store = new EventStore(config.dataDir);
|
||||
|
||||
return async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/healthz") {
|
||||
return textResponse("ok\n");
|
||||
}
|
||||
if (url.pathname === "/git-webhooks/github") {
|
||||
return handleGithub(request, config, store);
|
||||
}
|
||||
if (url.pathname === "/git-webhooks/jojo") {
|
||||
return handleJojo(request, config, store);
|
||||
}
|
||||
return jsonResponse({ error: "not_found" }, { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const port = Number(process.env.PORT ?? "3000");
|
||||
const hostname = process.env.HOST ?? "0.0.0.0";
|
||||
const config: ServerConfig = {
|
||||
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
|
||||
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
|
||||
dataDir: process.env.DATA_DIR ?? "./data",
|
||||
};
|
||||
|
||||
Bun.serve({
|
||||
hostname,
|
||||
port,
|
||||
fetch: createHandler(config),
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ type: "server.started", hostname, port }));
|
||||
}
|
||||
62
src/signatures.ts
Normal file
62
src/signatures.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function timingSafeEqualHex(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a, "hex");
|
||||
const right = Buffer.from(b, "hex");
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export async function hmacSha256Hex(secret: string, body: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
||||
return Buffer.from(signature).toString("hex");
|
||||
}
|
||||
|
||||
export async function verifyGithubSignature(
|
||||
secret: string,
|
||||
body: string,
|
||||
signatureHeader: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!secret || !signatureHeader?.startsWith("sha256=")) {
|
||||
return false;
|
||||
}
|
||||
const expected = await hmacSha256Hex(secret, body);
|
||||
const actual = signatureHeader.slice("sha256=".length).toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected);
|
||||
}
|
||||
|
||||
export async function verifyJojoSignature(
|
||||
secret: string,
|
||||
body: string,
|
||||
headers: Headers,
|
||||
): Promise<boolean> {
|
||||
if (!secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
headers.get("x-forgejo-signature-256"),
|
||||
headers.get("x-forgejo-signature"),
|
||||
headers.get("x-gitea-signature-256"),
|
||||
headers.get("x-gitea-signature"),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
const expected = await hmacSha256Hex(secret, body);
|
||||
return candidates.some((candidate) => {
|
||||
const actual = candidate.startsWith("sha256=")
|
||||
? candidate.slice("sha256=".length).toLowerCase()
|
||||
: candidate.toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected);
|
||||
});
|
||||
}
|
||||
45
src/types.ts
Normal file
45
src/types.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export type Provider = "jojo" | "github";
|
||||
|
||||
export type NormalizedEventName =
|
||||
| "ping"
|
||||
| "push"
|
||||
| "pull_request"
|
||||
| "workflow_run"
|
||||
| "release"
|
||||
| "unknown";
|
||||
|
||||
export type GitWebhookEvent = {
|
||||
provider: Provider;
|
||||
event: NormalizedEventName;
|
||||
providerEvent: string;
|
||||
deliveryId: string;
|
||||
receivedAt: string;
|
||||
repo?: {
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
cloneUrl?: string;
|
||||
sshUrl?: string;
|
||||
defaultBranch?: string;
|
||||
};
|
||||
sender?: {
|
||||
username: string;
|
||||
htmlUrl?: string;
|
||||
};
|
||||
ref?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
action?: string;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
export type QueuedJob = {
|
||||
id: string;
|
||||
kind: "main_push";
|
||||
provider: Provider;
|
||||
repoFullName: string;
|
||||
ref: string;
|
||||
sha: string;
|
||||
deliveryId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
54
test/providers.test.ts
Normal file
54
test/providers.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeGithubEvent } from "../src/providers/github";
|
||||
import { normalizeJojoEvent } from "../src/providers/jojo";
|
||||
|
||||
const repository = {
|
||||
name: "git-webhooks",
|
||||
full_name: "peezy-tech/git-webhooks",
|
||||
clone_url: "https://example.test/peezy-tech/git-webhooks.git",
|
||||
ssh_url: "git@example.test:peezy-tech/git-webhooks.git",
|
||||
default_branch: "main",
|
||||
owner: { login: "peezy-tech" },
|
||||
};
|
||||
|
||||
describe("provider normalization", () => {
|
||||
test("normalizes GitHub push events", () => {
|
||||
const event = normalizeGithubEvent({
|
||||
providerEvent: "push",
|
||||
deliveryId: "delivery-1",
|
||||
receivedAt: "2026-05-12T00:00:00.000Z",
|
||||
payload: {
|
||||
ref: "refs/heads/main",
|
||||
before: "before",
|
||||
after: "after",
|
||||
repository,
|
||||
sender: { login: "peezy", html_url: "https://github.com/peezy" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(event.provider).toBe("github");
|
||||
expect(event.event).toBe("push");
|
||||
expect(event.repo?.fullName).toBe("peezy-tech/git-webhooks");
|
||||
expect(event.sender?.username).toBe("peezy");
|
||||
});
|
||||
|
||||
test("normalizes jojo push events", () => {
|
||||
const event = normalizeJojoEvent({
|
||||
providerEvent: "push",
|
||||
deliveryId: "delivery-2",
|
||||
receivedAt: "2026-05-12T00:00:00.000Z",
|
||||
payload: {
|
||||
ref: "refs/heads/main",
|
||||
before: "before",
|
||||
after: "after",
|
||||
repository,
|
||||
sender: { username: "peezy", html_url: "https://jojo.build/peezy" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(event.provider).toBe("jojo");
|
||||
expect(event.event).toBe("push");
|
||||
expect(event.repo?.owner).toBe("peezy-tech");
|
||||
expect(event.sender?.username).toBe("peezy");
|
||||
});
|
||||
});
|
||||
60
test/server.test.ts
Normal file
60
test/server.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createHandler } from "../src/server";
|
||||
import { hmacSha256Hex } from "../src/signatures";
|
||||
|
||||
async function signedRequest(path: string, provider: "github" | "jojo", secret: string, body: unknown): Promise<Request> {
|
||||
const raw = JSON.stringify(body);
|
||||
const digest = await hmacSha256Hex(secret, raw);
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (provider === "github") {
|
||||
headers["x-hub-signature-256"] = `sha256=${digest}`;
|
||||
headers["x-github-event"] = "push";
|
||||
headers["x-github-delivery"] = "github-delivery";
|
||||
} else {
|
||||
headers["x-forgejo-signature-256"] = `sha256=${digest}`;
|
||||
headers["x-forgejo-event"] = "push";
|
||||
headers["x-forgejo-delivery"] = "jojo-delivery";
|
||||
}
|
||||
return new Request(`http://localhost${path}`, { method: "POST", headers, body: raw });
|
||||
}
|
||||
|
||||
describe("server", () => {
|
||||
test("healthz returns ok", async () => {
|
||||
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "git-webhooks-")) });
|
||||
const response = await handler(new Request("http://localhost/healthz"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("ok\n");
|
||||
});
|
||||
|
||||
test("rejects invalid signatures", async () => {
|
||||
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "git-webhooks-")) });
|
||||
const response = await handler(new Request("http://localhost/git-webhooks/github", {
|
||||
method: "POST",
|
||||
headers: { "x-hub-signature-256": "sha256=bad" },
|
||||
body: "{}",
|
||||
}));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("accepts jojo main pushes and queues a job", async () => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), "git-webhooks-"));
|
||||
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir });
|
||||
const request = await signedRequest("/git-webhooks/jojo", "jojo", "jojo", {
|
||||
ref: "refs/heads/main",
|
||||
after: "abc123",
|
||||
repository: {
|
||||
name: "git-webhooks",
|
||||
full_name: "peezy-tech/git-webhooks",
|
||||
owner: { username: "peezy-tech" },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await handler(request);
|
||||
expect(response.status).toBe(202);
|
||||
expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\"");
|
||||
expect(await readFile(join(dataDir, "jobs.jsonl"), "utf8")).toContain("\"kind\":\"main_push\"");
|
||||
});
|
||||
});
|
||||
21
test/signatures.test.ts
Normal file
21
test/signatures.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { hmacSha256Hex, verifyGithubSignature, verifyJojoSignature } from "../src/signatures";
|
||||
|
||||
describe("webhook signatures", () => {
|
||||
test("verifies GitHub sha256 signatures", async () => {
|
||||
const body = JSON.stringify({ ok: true });
|
||||
const digest = await hmacSha256Hex("secret", body);
|
||||
|
||||
expect(await verifyGithubSignature("secret", body, `sha256=${digest}`)).toBe(true);
|
||||
expect(await verifyGithubSignature("wrong", body, `sha256=${digest}`)).toBe(false);
|
||||
});
|
||||
|
||||
test("verifies jojo Forgejo/Gitea signature headers", async () => {
|
||||
const body = JSON.stringify({ ok: true });
|
||||
const digest = await hmacSha256Hex("secret", body);
|
||||
const headers = new Headers({ "x-forgejo-signature-256": `sha256=${digest}` });
|
||||
|
||||
expect(await verifyJojoSignature("secret", body, headers)).toBe(true);
|
||||
expect(await verifyJojoSignature("wrong", body, headers)).toBe(false);
|
||||
});
|
||||
});
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue