fc: initial pull request evaluation support

The migration adds PR support, models expose PR fields, webhooks handle
PR events, and tests validate it. To be honest the migrations are a bit
redundant at the moment, but I'd like to handle my old deployments so
it's nice(r) to have them. I *am* testing those on baremetal.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I02fb4540b62d3e8159ac18b9fa63be916a6a6964
This commit is contained in:
raf 2026-02-05 23:04:58 +03:00
commit 2eae49f313
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 1107 additions and 12 deletions

402
nix/tests/webhooks.nix Normal file
View file

@ -0,0 +1,402 @@
# Webhook and PR integration tests
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest {
name = "fc-webhooks";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;};
testScript = ''
import hashlib
import json
machine.start()
machine.wait_for_unit("postgresql.service")
# Ensure PostgreSQL is actually ready to accept connections before fc-server starts
machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30)
machine.wait_for_unit("fc-server.service")
# Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30)
# ---- Seed an API key for write operations ----
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\""
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# ---- Create a test project for webhook tests ----
with subtest("Create test project for webhooks"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"webhook-test\", \"repository_url\": \"https://github.com/test/webhook-repo\"}' "
"| jq -r .id"
)
project_id = result.strip()
assert len(project_id) == 36, f"Expected UUID, got '{project_id}'"
# ---- Create a jobset for the project ----
with subtest("Create jobset for webhook project"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"main\", \"nix_expression\": \"packages\", \"enabled\": true}' "
"| jq -r .id"
)
jobset_id = result.strip()
assert len(jobset_id) == 36, f"Expected UUID, got '{jobset_id}'"
# ========================================================================
# GitHub Webhook Tests
# ========================================================================
with subtest("GitHub webhook returns 404 when not configured"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
f"-X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-GitHub-Event: push' "
"-d '{\"after\": \"abc123def456\"}'"
)
# 200 with accepted=false (no webhook configured)
assert code.strip() in ("200", "404"), f"Expected 200 or 404, got {code.strip()}"
# Configure GitHub webhook for the project
with subtest("Configure GitHub webhook"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/webhooks "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"forge_type\": \"github\", \"secret\": \"test-secret\"}' "
"| jq -r .id"
)
webhook_id = result.strip()
assert len(webhook_id) == 36, f"Expected UUID, got '{webhook_id}'"
with subtest("GitHub push webhook triggers evaluation"):
# First count evaluations
count_before = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t"
).strip()
# Send push event (signature would normally be verified but we're testing)
# We need to compute HMAC-SHA256 signature
payload = '{"ref":"refs/heads/main","after":"abc123def456789012345678901234567890abcd"}'
import hmac
sig = hmac.new(b"test-secret", payload.encode(), hashlib.sha256).hexdigest()
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-GitHub-Event: push' "
f"-H 'X-Hub-Signature-256: sha256={sig}' "
f"-d '{payload}'"
)
# Check evaluation was created
count_after = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t"
).strip()
assert int(count_after) > int(count_before), \
f"Expected new evaluation, count before={count_before}, after={count_after}"
with subtest("GitHub push webhook rejects invalid signature"):
result = machine.succeed(
f"curl -s -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-GitHub-Event: push' "
"-H 'X-Hub-Signature-256: sha256=invalidsig' "
"-d '{\"after\": \"xyz789\"}' | jq -r .accepted"
)
assert result.strip() == "false", f"Expected accepted=false for invalid sig, got {result.strip()}"
with subtest("GitHub push webhook skips branch deletion"):
deletion_payload = '{"after": "0000000000000000000000000000000000000000"}'
deletion_sig = hmac.new(b"test-secret", deletion_payload.encode(), hashlib.sha256).hexdigest()
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-GitHub-Event: push' "
f"-H 'X-Hub-Signature-256: sha256={deletion_sig}' "
f"-d '{deletion_payload}' "
"| jq -r .message"
)
assert "deletion" in result.lower() or "skip" in result.lower(), \
f"Expected deletion event to be skipped, got: {result}"
with subtest("GitHub pull_request webhook triggers evaluation with PR metadata"):
count_before = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t"
).strip()
payload = json.dumps({
"action": "opened",
"number": 42,
"pull_request": {
"head": {"sha": "pr123abc456def789012345678901234567890ab", "ref": "feature-branch"},
"base": {"sha": "base456", "ref": "main"},
"draft": False
}
})
sig = hmac.new(b"test-secret", payload.encode(), hashlib.sha256).hexdigest()
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-GitHub-Event: pull_request' "
f"-H 'X-Hub-Signature-256: sha256={sig}' "
f"-d '{payload}'"
)
count_after = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t"
).strip()
assert int(count_after) > int(count_before), \
f"Expected PR evaluation, count before={count_before}, after={count_after}"
# Verify PR metadata was stored
pr_data = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT pr_number, pr_head_branch, pr_base_branch FROM evaluations WHERE pr_number = 42\" -t"
).strip()
assert "42" in pr_data, f"Expected PR number 42 in {pr_data}"
assert "feature-branch" in pr_data, f"Expected feature-branch in {pr_data}"
with subtest("GitHub pull_request webhook skips draft PRs"):
payload = json.dumps({
"action": "opened",
"number": 99,
"pull_request": {
"head": {"sha": "draft123", "ref": "draft-branch"},
"base": {"sha": "base456", "ref": "main"},
"draft": True
}
})
sig = hmac.new(b"test-secret", payload.encode(), hashlib.sha256).hexdigest()
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github "
"-H 'Content-Type: application/json' "
"-H 'X-GitHub-Event: pull_request' "
f"-H 'X-Hub-Signature-256: sha256={sig}' "
f"-d '{payload}' | jq -r .message"
)
assert "draft" in result.lower(), f"Expected draft PR to be skipped, got: {result}"
# ========================================================================
# GitLab Webhook Tests
# ========================================================================
# Create a GitLab project
with subtest("Create GitLab test project"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"gitlab-test\", \"repository_url\": \"https://gitlab.com/test/repo\"}' "
"| jq -r .id"
)
gitlab_project_id = result.strip()
assert len(gitlab_project_id) == 36, f"Expected UUID, got '{gitlab_project_id}'"
with subtest("Create jobset for GitLab project"):
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitlab_project_id}/jobsets "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"main\", \"nix_expression\": \"packages\", \"enabled\": true}'"
)
with subtest("Configure GitLab webhook"):
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitlab_project_id}/webhooks "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"forge_type\": \"gitlab\", \"secret\": \"gitlab-token\"}' "
"| jq -r .id"
)
assert len(result.strip()) == 36
with subtest("GitLab Push Hook triggers evaluation"):
count_before = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t"
).strip()
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab "
"-H 'Content-Type: application/json' "
"-H 'X-Gitlab-Event: Push Hook' "
"-H 'X-Gitlab-Token: gitlab-token' "
"-d '{\"ref\":\"refs/heads/main\",\"checkout_sha\":\"gitlab123456789012345678901234567890abcd\"}'"
)
count_after = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t"
).strip()
assert int(count_after) > int(count_before), \
"Expected new evaluation from GitLab push"
with subtest("GitLab webhook rejects invalid token"):
result = machine.succeed(
f"curl -s -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab "
"-H 'Content-Type: application/json' "
"-H 'X-Gitlab-Event: Push Hook' "
"-H 'X-Gitlab-Token: wrong-token' "
"-d '{\"checkout_sha\":\"abc123\"}' | jq -r .accepted"
)
assert result.strip() == "false", "Expected rejected for wrong token"
with subtest("GitLab Merge Request Hook triggers evaluation with PR metadata"):
count_before = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t"
).strip()
payload = json.dumps({
"object_kind": "merge_request",
"object_attributes": {
"iid": 123,
"action": "open",
"source_branch": "feature",
"target_branch": "main",
"last_commit": {"id": "mr123abc456def789012345678901234567890ab"},
"draft": False,
"work_in_progress": False
}
})
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab "
"-H 'Content-Type: application/json' "
"-H 'X-Gitlab-Event: Merge Request Hook' "
"-H 'X-Gitlab-Token: gitlab-token' "
f"-d '{payload}'"
)
count_after = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t"
).strip()
assert int(count_after) > int(count_before), \
f"Expected MR evaluation, count before={count_before}, after={count_after}"
with subtest("GitLab Merge Request Hook skips draft MRs"):
payload = json.dumps({
"object_kind": "merge_request",
"object_attributes": {
"iid": 999,
"action": "open",
"source_branch": "draft-feature",
"target_branch": "main",
"last_commit": {"id": "draft123"},
"draft": True
}
})
result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab "
"-H 'Content-Type: application/json' "
"-H 'X-Gitlab-Event: Merge Request Hook' "
"-H 'X-Gitlab-Token: gitlab-token' "
f"-d '{payload}' | jq -r .message"
)
assert "draft" in result.lower() or "wip" in result.lower(), \
f"Expected draft MR to be skipped, got: {result}"
# ========================================================================
# Gitea/Forgejo Webhook Tests
# ========================================================================
with subtest("Create Gitea test project"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"gitea-test\", \"repository_url\": \"https://gitea.example.com/test/repo\"}' "
"| jq -r .id"
)
gitea_project_id = result.strip()
with subtest("Create jobset for Gitea project"):
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitea_project_id}/jobsets "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"name\": \"main\", \"nix_expression\": \"packages\", \"enabled\": true}'"
)
with subtest("Configure Gitea webhook"):
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitea_project_id}/webhooks "
f"{auth_header} "
"-H 'Content-Type: application/json' "
"-d '{\"forge_type\": \"gitea\", \"secret\": \"gitea-secret\"}'"
)
with subtest("Gitea push webhook triggers evaluation"):
count_before = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t"
).strip()
payload = '{"ref":"refs/heads/main","after":"gitea123456789012345678901234567890abcd"}'
sig = hmac.new(b"gitea-secret", payload.encode(), hashlib.sha256).hexdigest()
machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitea_project_id}/gitea "
"-H 'Content-Type: application/json' "
f"-H 'X-Gitea-Signature: {sig}' "
f"-d '{payload}'"
)
count_after = machine.succeed(
"sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t"
).strip()
assert int(count_after) > int(count_before), \
"Expected new evaluation from Gitea push"
# ========================================================================
# OAuth Routes Existence Tests
# ========================================================================
with subtest("GitHub OAuth login route exists"):
# Should redirect or return 404 if not configured
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/auth/github"
)
# 302 (redirect) or 404 (not configured) are both acceptable
assert code.strip() in ("302", "404"), f"Expected 302 or 404, got {code.strip()}"
with subtest("GitHub OAuth callback route exists"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:3000/api/v1/auth/github/callback?code=test&state=test'"
)
# Should fail gracefully (no OAuth configured)
assert code.strip() in ("400", "404", "500"), f"Expected error code, got {code.strip()}"
# ========================================================================
# Cleanup
# ========================================================================
with subtest("Cleanup test projects"):
machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{project_id} {auth_header}"
)
machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{gitlab_project_id} {auth_header}"
)
machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{gitea_project_id} {auth_header}"
)
'';
}