circus/nix/tests/webhooks.nix
NotAShelf 62f8cdf4de
nix: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia88656a1d6bb152398a5c4ce83d40a3e6a6a6964
2026-02-07 22:09:29 +03:00

389 lines
16 KiB
Nix

{pkgs, self}:
pkgs.testers.nixosTest {
name = "fc-webhooks";
nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
# Webhook and PR integration tests
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}"
)
'';
}