Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia88656a1d6bb152398a5c4ce83d40a3e6a6a6964
389 lines
16 KiB
Nix
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}"
|
|
)
|
|
'';
|
|
}
|