diff --git a/app/Exceptions/InvalidWebhookPayloadException.php b/app/Exceptions/InvalidWebhookPayloadException.php new file mode 100644 index 0000000000..d1d2c383f9 --- /dev/null +++ b/app/Exceptions/InvalidWebhookPayloadException.php @@ -0,0 +1,19 @@ +webhookString(data_get($payload, 'push.changes.0.new.name')); $full_name = data_get($payload, 'repository.full_name'); - $commit = $this->webhookString(data_get($payload, 'push.changes.0.new.target.hash')); + $commit = $this->webhookCommitSha($payload, 'push.changes.0.new.target.hash'); // Bitbucket webhooks ship up to 5 commits per change. Larger pushes // are evaluated only on the visible 5. $changes = data_get($payload, 'push.changes'); @@ -61,11 +62,10 @@ class Bitbucket extends Controller $branch = $this->webhookString(data_get($payload, 'pullrequest.destination.branch.name')); $base_branch = $this->webhookString(data_get($payload, 'pullrequest.source.branch.name')); $full_name = data_get($payload, 'repository.full_name'); - $pull_request_id = data_get($payload, 'pullrequest.id'); - $pull_request_html_url = data_get($payload, 'pullrequest.links.html.href'); - $pull_request_title = data_get($payload, 'pullrequest.title'); - $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); - $commit = data_get($payload, 'pullrequest.source.commit.hash'); + $pull_request_id = $this->webhookPullRequestId($payload, 'pullrequest.id'); + $pull_request_html_url = $this->webhookPayloadUrl($payload, 'pullrequest.links.html.href'); + $skip_deploy_pr = self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pullrequest.title')]); + $commit = $this->webhookCommitSha($payload, 'pullrequest.source.commit.hash'); if (! $branch) { return response([ @@ -222,7 +222,7 @@ class Bitbucket extends Controller 'git_type' => 'bitbucket', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', 'docker_compose_domains' => $application->docker_compose_domains, ]); $pr_app->generate_preview_fqdn_compose(); @@ -231,7 +231,7 @@ class Bitbucket extends Controller 'git_type' => 'bitbucket', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', ]); $pr_app->generate_preview_fqdn(); } @@ -291,6 +291,11 @@ class Bitbucket extends Controller } return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); + } catch (InvalidWebhookPayloadException $e) { + return response([ + 'status' => 'failed', + 'message' => $e->getMessage(), + ]); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php b/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php index c41ab3c04f..5c7d5e3862 100644 --- a/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php +++ b/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php @@ -2,19 +2,37 @@ namespace App\Http\Controllers\Webhook\Concerns; +use App\Exceptions\InvalidWebhookPayloadException; use App\Models\Application; use Illuminate\Support\Collection; use Illuminate\Support\Str; /** - * Reads push webhook payloads defensively. + * Reads webhook payloads defensively. * * Git hosts omit the commit list (or send null) for some pushes, for example * branch creation, branch deletion and some system hooks. Such payloads must * not crash the handler and must not skip a deployment because of watch paths. + * + * The typed readers (webhookPayloadString(), webhookPayloadId(), + * webhookCommitSha(), webhookPayloadUrl()) throw an + * InvalidWebhookPayloadException for a value with a wrong type or format. The + * handlers catch it and send a clean "Nothing to do." response, so a malformed + * but correctly signed payload never causes a 500 or a deployment. */ trait ReadsWebhookPushPayload { + /** + * Largest value of an integer database column (pull_request_id, + * repository_project_id). + */ + protected const WEBHOOK_MAX_DATABASE_INTEGER = 2147483647; + + /** + * Length of the application_previews.pull_request_html_url column. + */ + protected const WEBHOOK_MAX_URL_LENGTH = 255; + /** * Branch name from a ref such as "refs/heads/main", or null when the ref is * missing or not a string. @@ -38,6 +56,128 @@ trait ReadsWebhookPushPayload return is_string($value) && $value !== '' ? $value : null; } + /** + * A string field of the payload. A missing, null or empty value gives null. + * + * @throws InvalidWebhookPayloadException When the value is not a string, or is missing and required. + */ + protected function webhookPayloadString(mixed $payload, string $key, bool $required = false): ?string + { + $value = data_get($payload, $key); + if ($value === null || $value === '') { + if ($required) { + throw InvalidWebhookPayloadException::forField($key); + } + + return null; + } + + if (! is_string($value)) { + throw InvalidWebhookPayloadException::forField($key); + } + + return $value; + } + + /** + * A positive integer id of the payload, sent as an integer or as a string + * of digits. A missing or null value gives null. + * + * @throws InvalidWebhookPayloadException When the value is not a positive integer up to $max, or is missing and required. + */ + protected function webhookPayloadId(mixed $payload, string $key, bool $required = false, int $max = PHP_INT_MAX): ?int + { + $value = data_get($payload, $key); + if ($value === null) { + if ($required) { + throw InvalidWebhookPayloadException::forField($key); + } + + return null; + } + + if (is_string($value) && preg_match('/\A[1-9][0-9]{0,18}\z/', $value) === 1) { + $value = filter_var($value, FILTER_VALIDATE_INT); + } + + if (! is_int($value) || $value < 1 || $value > $max) { + throw InvalidWebhookPayloadException::forField($key); + } + + return $value; + } + + /** + * An id that Coolify stores in or compares with an integer database + * column, for example a pull request id or a repository project id. + * + * @throws InvalidWebhookPayloadException + */ + protected function webhookPayloadDatabaseId(mixed $payload, string $key, bool $required = false): ?int + { + return $this->webhookPayloadId($payload, $key, $required, self::WEBHOOK_MAX_DATABASE_INTEGER); + } + + /** + * A pull request or merge request id. The id is required. + * + * @throws InvalidWebhookPayloadException + */ + protected function webhookPullRequestId(mixed $payload, string $key): int + { + return $this->webhookPayloadDatabaseId($payload, $key, required: true); + } + + /** + * A commit SHA of the payload: 7 to 64 hexadecimal characters. A missing, + * null or empty value gives null. + * + * @throws InvalidWebhookPayloadException When the value is not a commit SHA. + */ + protected function webhookCommitSha(mixed $payload, string $key): ?string + { + $value = data_get($payload, $key); + if ($value === null || $value === '') { + return null; + } + + if (! is_string($value) || preg_match('/\A[0-9a-fA-F]{7,64}\z/', $value) !== 1) { + throw InvalidWebhookPayloadException::forField($key); + } + + return $value; + } + + /** + * An absolute http or https URL of the payload, for example a pull request + * link that Coolify stores and shows in the UI. A missing, null or empty + * value gives null. + * + * @throws InvalidWebhookPayloadException When the value is not a valid URL, or is missing and required. + */ + protected function webhookPayloadUrl(mixed $payload, string $key, bool $required = false): ?string + { + $value = $this->webhookPayloadString($payload, $key, $required); + if ($value === null) { + return null; + } + + $scheme = parse_url($value, PHP_URL_SCHEME); + $host = parse_url($value, PHP_URL_HOST); + if ( + strlen($value) > self::WEBHOOK_MAX_URL_LENGTH + || preg_match('/[\s\x00-\x1F\x7F]/', $value) === 1 + || ! is_string($scheme) + || ! in_array(strtolower($scheme), ['http', 'https'], true) + || ! is_string($host) + || $host === '' + ) { + throw InvalidWebhookPayloadException::forField($key); + } + + return $value; + } + /** * Files that the commits of a push add, remove or modify. * diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 9109d047eb..83c1eaf887 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webhook; use App\Actions\Application\CleanupPreviewDeployment; +use App\Exceptions\InvalidWebhookPayloadException; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; @@ -45,20 +46,21 @@ class Gitea extends Controller if ($x_gitea_event === 'push') { $branch = $this->webhookPushBranch(data_get($payload, 'ref')); $full_name = data_get($payload, 'repository.full_name'); + $commit = $this->webhookCommitSha($payload, 'after'); $changed_files = $this->webhookPushChangedFiles($payload); $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_gitea_event === 'pull_request') { - $action = data_get($payload, 'action'); + $action = $this->webhookPayloadString($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); - $pull_request_id = data_get($payload, 'number'); - $pull_request_html_url = data_get($payload, 'pull_request.html_url'); - $pull_request_title = data_get($payload, 'pull_request.title'); - $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); + $pull_request_id = $this->webhookPullRequestId($payload, 'number'); + $pull_request_html_url = $this->webhookPayloadUrl($payload, 'pull_request.html_url'); + $skip_deploy_pr = self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pull_request.title')]); $branch = $this->webhookString(data_get($payload, 'pull_request.head.ref')); $base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref')); + $commit = $this->webhookCommitSha($payload, 'head.sha'); } - if (! $branch) { + if (! $branch || ($x_gitea_event === 'pull_request' && ! $base_branch)) { return response('Nothing to do. No branch found in the request.'); } // A deleted branch has no commit to deploy. No secret is checked here. @@ -143,7 +145,7 @@ class Gitea extends Controller application: $application, deployment_uuid: $deployment_uuid, force_rebuild: false, - commit: data_get($payload, 'after', 'HEAD'), + commit: $commit ?? 'HEAD', is_webhook: true, ); if ($result['status'] === 'queue_full') { @@ -161,7 +163,7 @@ class Gitea extends Controller 'application_uuid' => $application->uuid, 'application_name' => $application->name, 'deployment_uuid' => $deployment_uuid, - 'commit' => data_get($payload, 'after'), + 'commit' => $commit, 'repository' => $full_name ?? null, ]); $return_payloads->push([ @@ -222,7 +224,7 @@ class Gitea extends Controller 'git_type' => 'gitea', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', 'docker_compose_domains' => $application->docker_compose_domains, ]); $pr_app->generate_preview_fqdn_compose(); @@ -231,7 +233,7 @@ class Gitea extends Controller 'git_type' => 'gitea', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', ]); $pr_app->generate_preview_fqdn(); } @@ -241,7 +243,7 @@ class Gitea extends Controller pull_request_id: $pull_request_id, deployment_uuid: $deployment_uuid, force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), + commit: $commit ?? 'HEAD', is_webhook: true, git_type: 'gitea' ); @@ -292,6 +294,8 @@ class Gitea extends Controller } return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); + } catch (InvalidWebhookPayloadException $e) { + return response($e->getMessage()); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 2f56ad3fb8..fb36ea3ab1 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Webhook; +use App\Exceptions\InvalidWebhookPayloadException; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; @@ -46,26 +47,30 @@ class Github extends Controller if ($x_github_event === 'push') { $branch = $this->webhookPushBranch(data_get($payload, 'ref')); $full_name = data_get($payload, 'repository.full_name'); + $commit = $this->webhookCommitSha($payload, 'after'); $changed_files = $this->webhookPushChangedFiles($payload); $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_github_event === 'pull_request') { - $action = data_get($payload, 'action'); + [ + 'action' => $action, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_title' => $pull_request_title, + 'branch' => $branch, + 'base_branch' => $base_branch, + 'before_sha' => $before_sha, + 'after_sha' => $after_sha, + 'commit_sha' => $commit_sha, + 'author_association' => $author_association, + 'is_fork_pull_request' => $is_fork_pull_request, + ] = $this->readPullRequestPayload($payload); $full_name = data_get($payload, 'repository.full_name'); - $pull_request_id = data_get($payload, 'number'); - $pull_request_html_url = data_get($payload, 'pull_request.html_url'); - $pull_request_title = data_get($payload, 'pull_request.title'); - $branch = $this->webhookString(data_get($payload, 'pull_request.head.ref')); - $base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref')); - $before_sha = data_get($payload, 'before'); - $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); - $author_association = data_get($payload, 'pull_request.author_association'); - $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); } - if (! $branch) { + if (! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) { return response('Nothing to do. No branch found in the request.'); } // A deleted branch has no commit to deploy. No secret is checked here. @@ -154,7 +159,7 @@ class Github extends Controller application: $application, deployment_uuid: $deployment_uuid, force_rebuild: false, - commit: data_get($payload, 'after', 'HEAD'), + commit: $commit ?? 'HEAD', is_webhook: true, ); if ($result['status'] === 'queue_full') { @@ -172,7 +177,7 @@ class Github extends Controller 'application_uuid' => $application->uuid, 'application_name' => $application->name, 'deployment_uuid' => $result['deployment_uuid'], - 'commit' => data_get($payload, 'after'), + 'commit' => $commit, 'repository' => $full_name ?? null, ]); $return_payloads->push([ @@ -224,13 +229,13 @@ class Github extends Controller action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, - pullRequestTitle: $pull_request_title ?? null, + pullRequestTitle: $pull_request_title, beforeSha: $before_sha, afterSha: $after_sha, - commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), + commitSha: $commit_sha, authorAssociation: $author_association, fullName: $full_name, - isForkPullRequest: $is_fork_pull_request ?? false, + isForkPullRequest: $is_fork_pull_request, ); $return_payloads->push([ @@ -243,6 +248,8 @@ class Github extends Controller } return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); + } catch (InvalidWebhookPayloadException $e) { + return response($e->getMessage()); } catch (Exception $e) { return handleError($e); } @@ -254,9 +261,9 @@ class Github extends Controller $return_payloads = collect([]); $id = null; $x_github_delivery = $request->header('X-GitHub-Delivery'); - $x_github_event = Str::lower($request->header('X-GitHub-Event')); + $x_github_event = Str::lower((string) $request->header('X-GitHub-Event')); $x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id'); - $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); + $x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256='); $payload = $request->collect(); if ($x_github_event === 'ping') { // Just pong @@ -300,28 +307,36 @@ class Github extends Controller return response('cool'); } if ($x_github_event === 'push') { - $id = data_get($payload, 'repository.id'); + $id = $this->webhookPayloadDatabaseId($payload, 'repository.id'); $branch = $this->webhookPushBranch(data_get($payload, 'ref')); + $commit = $this->webhookCommitSha($payload, 'after'); $changed_files = $this->webhookPushChangedFiles($payload); $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_github_event === 'pull_request') { - $action = data_get($payload, 'action'); - $id = data_get($payload, 'repository.id'); - $pull_request_id = data_get($payload, 'number'); - $pull_request_html_url = data_get($payload, 'pull_request.html_url'); - $pull_request_title = data_get($payload, 'pull_request.title'); - $branch = data_get($payload, 'pull_request.head.ref'); - $base_branch = data_get($payload, 'pull_request.base.ref'); - $before_sha = data_get($payload, 'before'); - $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); - $author_association = data_get($payload, 'pull_request.author_association'); - $is_fork_pull_request = $this->isForkPullRequest($payload); + $id = $this->webhookPayloadDatabaseId($payload, 'repository.id'); + [ + 'action' => $action, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_title' => $pull_request_title, + 'branch' => $branch, + 'base_branch' => $base_branch, + 'before_sha' => $before_sha, + 'after_sha' => $after_sha, + 'commit_sha' => $commit_sha, + 'author_association' => $author_association, + 'is_fork_pull_request' => $is_fork_pull_request, + ] = $this->readPullRequestPayload($payload); + $full_name = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name')); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); } - if (! $id || ! $branch) { + if (! $id || ! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) { return response('Nothing to do. No id or branch found.'); } if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) { @@ -380,7 +395,7 @@ class Github extends Controller $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'after', 'HEAD'), + commit: $commit ?? 'HEAD', force_rebuild: false, is_webhook: true, ); @@ -394,7 +409,7 @@ class Github extends Controller 'application_uuid' => $application->uuid, 'application_name' => $application->name, 'deployment_uuid' => $result['deployment_uuid'], - 'commit' => data_get($payload, 'after'), + 'commit' => $commit, 'github_app_id' => $github_app->id, ]); } @@ -439,21 +454,19 @@ class Github extends Controller continue; } - $full_name = data_get($payload, 'repository.full_name'); - ProcessGithubPullRequestWebhook::dispatch( applicationId: $application->id, githubAppId: $github_app->id, action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, - pullRequestTitle: $pull_request_title ?? null, + pullRequestTitle: $pull_request_title, beforeSha: $before_sha, afterSha: $after_sha, - commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), + commitSha: $commit_sha, authorAssociation: $author_association, fullName: $full_name, - isForkPullRequest: $is_fork_pull_request ?? false, + isForkPullRequest: $is_fork_pull_request, ); $return_payloads->push([ @@ -466,11 +479,39 @@ class Github extends Controller } return response($return_payloads); + } catch (InvalidWebhookPayloadException $e) { + return response($e->getMessage()); } catch (Exception $e) { return handleError($e); } } + /** + * Read and validate the pull_request payload fields that the handlers use. + * + * @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, author_association: ?string, is_fork_pull_request: bool} + * + * @throws InvalidWebhookPayloadException When a field has a wrong type or format. + */ + private function readPullRequestPayload(mixed $payload): array + { + $head_sha = $this->webhookCommitSha($payload, 'pull_request.head.sha'); + + return [ + 'action' => $this->webhookPayloadString($payload, 'action', required: true), + 'pull_request_id' => $this->webhookPullRequestId($payload, 'number'), + 'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'pull_request.html_url', required: true), + 'pull_request_title' => $this->webhookPayloadString($payload, 'pull_request.title'), + 'branch' => $this->webhookString(data_get($payload, 'pull_request.head.ref')), + 'base_branch' => $this->webhookString(data_get($payload, 'pull_request.base.ref')), + 'before_sha' => $this->webhookCommitSha($payload, 'before'), + 'after_sha' => $this->webhookCommitSha($payload, 'after') ?? $head_sha, + 'commit_sha' => $head_sha ?? 'HEAD', + 'author_association' => $this->webhookPayloadString($payload, 'pull_request.author_association'), + 'is_fork_pull_request' => $this->isForkPullRequest($payload), + ]; + } + /** * Determine whether a pull_request webhook payload originates from a fork. * @@ -481,14 +522,16 @@ class Github extends Controller * The repository id comparison is the canonical signal; the `head.repo.fork` * flag and a case-insensitive full_name comparison are fallbacks for payloads * where the ids are unavailable (e.g. a deleted head repository). + * + * @throws InvalidWebhookPayloadException When a repository id is not a positive integer. */ private function isForkPullRequest(mixed $payload): bool { - $headRepoId = data_get($payload, 'pull_request.head.repo.id'); - $baseRepoId = data_get($payload, 'pull_request.base.repo.id'); + $headRepoId = $this->webhookPayloadId($payload, 'pull_request.head.repo.id'); + $baseRepoId = $this->webhookPayloadId($payload, 'pull_request.base.repo.id'); if ($headRepoId !== null && $baseRepoId !== null) { - return (string) $headRepoId !== (string) $baseRepoId; + return $headRepoId !== $baseRepoId; } if (data_get($payload, 'pull_request.head.repo.fork') === true) { diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 4d26d3f5ac..27138135ef 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webhook; use App\Actions\Application\CleanupPreviewDeployment; +use App\Exceptions\InvalidWebhookPayloadException; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; @@ -85,11 +86,10 @@ class Gitlab extends Controller $return_payloads = collect([]); $payload = $request->collect(); $x_gitlab_token = $request->header('X-Gitlab-Token'); - $object_kind = data_get($payload, 'object_kind'); - $project_id = data_get($payload, 'project.id'); + $object_kind = $this->webhookString(data_get($payload, 'object_kind')); $allowed_events = ['push', 'merge_request']; - if (! in_array($object_kind, $allowed_events)) { + if (! in_array($object_kind, $allowed_events, true)) { return response([ 'status' => 'failed', 'message' => 'Event not allowed. Only push and merge_request events are allowed.', @@ -119,6 +119,7 @@ class Gitlab extends Controller ], 401); } + $project_id = $this->webhookPayloadDatabaseId($payload, 'project.id', required: true); $applications = Application::where('source_id', $gitlab_app->id) ->where('source_type', GitlabApp::class) ->where('repository_project_id', $project_id); @@ -137,6 +138,7 @@ class Gitlab extends Controller 'message' => 'Nothing to do. Branch deleted.', ]); } + $commit = $this->webhookCommitSha($payload, 'after'); $applications = $applications->where('git_branch', $branch)->get(); $changed_files = $this->webhookPushChangedFiles($payload); @@ -187,7 +189,7 @@ class Gitlab extends Controller $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'after', 'HEAD'), + commit: $commit ?? 'HEAD', force_rebuild: false, is_webhook: true, ); @@ -202,7 +204,7 @@ class Gitlab extends Controller 'application_uuid' => $application->uuid, 'application_name' => $application->name, 'deployment_uuid' => $deployment_uuid->toString(), - 'commit' => data_get($payload, 'after'), + 'commit' => $commit, ]); $return_payloads->push([ @@ -214,14 +216,21 @@ class Gitlab extends Controller } if ($object_kind === 'merge_request') { - $action = data_get($payload, 'object_attributes.action'); - $branch = data_get($payload, 'object_attributes.source_branch'); - $base_branch = data_get($payload, 'object_attributes.target_branch'); - $pull_request_id = data_get($payload, 'object_attributes.iid'); - $pull_request_html_url = data_get($payload, 'object_attributes.url'); - $pull_request_title = data_get($payload, 'object_attributes.title'); - $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); - $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); + $branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch')); + $base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch')); + if (! $branch || ! $base_branch) { + return response([ + 'status' => 'failed', + 'message' => 'No branch found in the request.', + ]); + } + [ + 'action' => $action, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'commit' => $commit, + 'skip_deploy' => $skip_deploy_pr, + ] = $this->readMergeRequestPayload($payload); $applications = $applications->where('git_branch', $base_branch)->get(); @@ -236,7 +245,7 @@ class Gitlab extends Controller continue; } - if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'])) { + if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'], true)) { if (! $application->isPRDeployable()) { $return_payloads->push([ 'application' => $application->name, @@ -277,7 +286,7 @@ class Gitlab extends Controller 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', 'docker_compose_domains' => $application->docker_compose_domains, ]); $pr_app->generate_preview_fqdn_compose(); @@ -286,7 +295,7 @@ class Gitlab extends Controller 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', ]); $pr_app->generate_preview_fqdn(); } @@ -296,7 +305,7 @@ class Gitlab extends Controller application: $application, pull_request_id: $pull_request_id, deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'), + commit: $commit ?? 'HEAD', force_rebuild: false, is_webhook: true, git_type: 'gitlab', @@ -311,7 +320,7 @@ class Gitlab extends Controller 'status' => $result['status'] ?? 'success', 'message' => $result['message'] ?? 'Preview Deployment queued', ]); - } elseif (in_array($action, ['closed', 'close', 'merge'])) { + } elseif (in_array($action, ['closed', 'close', 'merge'], true)) { $found = ApplicationPreview::where('application_id', $application->id) ->where('pull_request_id', $pull_request_id) ->first(); @@ -329,6 +338,11 @@ class Gitlab extends Controller } return response($return_payloads); + } catch (InvalidWebhookPayloadException $e) { + return response([ + 'status' => 'failed', + 'message' => $e->getMessage(), + ]); } catch (Exception $e) { return handleError($e); } @@ -341,9 +355,9 @@ class Gitlab extends Controller $payload = $request->collect(); $headers = $request->headers->all(); $x_gitlab_token = data_get($headers, 'x-gitlab-token.0'); - $x_gitlab_event = data_get($payload, 'object_kind'); + $x_gitlab_event = $this->webhookString(data_get($payload, 'object_kind')); $allowed_events = ['push', 'merge_request']; - if (! in_array($x_gitlab_event, $allowed_events)) { + if (! in_array($x_gitlab_event, $allowed_events, true)) { $return_payloads->push([ 'status' => 'failed', 'message' => 'Event not allowed. Only push and merge_request events are allowed.', @@ -382,19 +396,14 @@ class Gitlab extends Controller return response($return_payloads); } + $commit = $this->webhookCommitSha($payload, 'after'); $changed_files = $this->webhookPushChangedFiles($payload); $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_gitlab_event === 'merge_request') { - $action = data_get($payload, 'object_attributes.action'); $branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch')); $base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch')); $full_name = data_get($payload, 'project.path_with_namespace'); - $pull_request_id = data_get($payload, 'object_attributes.iid'); - $pull_request_html_url = data_get($payload, 'object_attributes.url'); - $pull_request_title = data_get($payload, 'object_attributes.title'); - $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); - $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); if (! $branch || ! $base_branch) { $return_payloads->push([ 'status' => 'failed', @@ -403,6 +412,13 @@ class Gitlab extends Controller return response($return_payloads); } + [ + 'action' => $action, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'commit' => $commit, + 'skip_deploy' => $skip_deploy_pr, + ] = $this->readMergeRequestPayload($payload); } $full_name = $this->manualWebhookRepositoryFullName($full_name); if ($full_name === null) { @@ -485,7 +501,7 @@ class Gitlab extends Controller $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'after', 'HEAD'), + commit: $commit ?? 'HEAD', force_rebuild: false, is_webhook: true, ); @@ -505,7 +521,7 @@ class Gitlab extends Controller 'application_uuid' => $application->uuid, 'application_name' => $application->name, 'deployment_uuid' => $deployment_uuid, - 'commit' => data_get($payload, 'after'), + 'commit' => $commit, 'repository' => $full_name ?? null, ]); $return_payloads->push([ @@ -566,7 +582,7 @@ class Gitlab extends Controller 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', 'docker_compose_domains' => $application->docker_compose_domains, ]); $pr_app->generate_preview_fqdn_compose(); @@ -575,7 +591,7 @@ class Gitlab extends Controller 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', ]); $pr_app->generate_preview_fqdn(); } @@ -584,7 +600,7 @@ class Gitlab extends Controller application: $application, pull_request_id: $pull_request_id, deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'), + commit: $commit ?? 'HEAD', force_rebuild: false, is_webhook: true, git_type: 'gitlab' @@ -639,8 +655,33 @@ class Gitlab extends Controller } return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); + } catch (InvalidWebhookPayloadException $e) { + return response([[ + 'status' => 'failed', + 'message' => $e->getMessage(), + ]]); } catch (Exception $e) { return handleError($e); } } + + /** + * Read and validate the merge_request payload fields that the handlers use. + * + * @return array{action: ?string, pull_request_id: int, pull_request_html_url: ?string, commit: ?string, skip_deploy: bool} + * + * @throws InvalidWebhookPayloadException When a field has a wrong type or format. + */ + private function readMergeRequestPayload(mixed $payload): array + { + $title = $this->webhookPayloadString($payload, 'object_attributes.title'); + + return [ + 'action' => $this->webhookPayloadString($payload, 'object_attributes.action'), + 'pull_request_id' => $this->webhookPullRequestId($payload, 'object_attributes.iid'), + 'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'object_attributes.url'), + 'commit' => $this->webhookCommitSha($payload, 'object_attributes.last_commit.id'), + 'skip_deploy' => self::shouldSkipDeployAny([$title, data_get($payload, 'object_attributes.last_commit.message')]), + ]; + } } diff --git a/tests/Feature/GithubPullRequestWebhookRoutingTest.php b/tests/Feature/GithubPullRequestWebhookRoutingTest.php index 7768dbd903..25102cfb1e 100644 --- a/tests/Feature/GithubPullRequestWebhookRoutingTest.php +++ b/tests/Feature/GithubPullRequestWebhookRoutingTest.php @@ -52,7 +52,7 @@ function githubPullRequestPayload(string $action, string $baseBranch): array 'author_association' => 'OWNER', 'head' => [ 'ref' => 'feature/child', - 'sha' => 'head-sha', + 'sha' => 'e83c5163316f89bfbde7d9ab23ca2e25604af290', ], 'base' => [ 'ref' => $baseBranch, diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php index c9ad4a76dc..247cd1125c 100644 --- a/tests/Feature/Security/AuditLogTest.php +++ b/tests/Feature/Security/AuditLogTest.php @@ -240,7 +240,7 @@ describe('webhook signature failure logging', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -272,7 +272,7 @@ describe('webhook signature failure logging', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], [ 'X-Gitlab-Token' => 'wrong-token', @@ -297,7 +297,7 @@ describe('webhook signature failure logging', function () { Log::shouldReceive('error')->andReturnNull(); $payload = json_encode([ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => 'test-org/test-repo'], ]); @@ -328,7 +328,7 @@ describe('webhook signature failure logging', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index c928d361ec..588e4dccf0 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -38,7 +38,7 @@ test('manual webhook routes are not rate limited per request', function (string * An invalid delivery uses a new random wrong secret unless $wrongSecret is set, * so each invalid delivery is a new guess (a new token or a new signature). */ -function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', string $branch = 'main', ?string $wrongSecret = null, string $commit = 'abc123'): TestResponse +function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', string $branch = 'main', ?string $wrongSecret = null, string $commit = 'abc1234'): TestResponse { $secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : ($wrongSecret ?? 'wrong-secret-'.Str::random(24)); $server = ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json']; @@ -212,13 +212,13 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () { // Each delivery has a different payload. Identical redeliveries count once. for ($i = 0; $i < 30; $i++) { - $response = sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: "commit-{$i}"); + $response = sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: sprintf('abc%04d', $i)); $response->assertOk(); expect($response->getContent())->toContain('Invalid signature'); } - sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: 'commit-30')->assertStatus(429); + sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: 'abc0030')->assertStatus(429); }); test('valid deliveries do not count when another matching application has a different secret', function () { @@ -327,7 +327,7 @@ describe('GitHub Manual Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -347,7 +347,7 @@ describe('GitHub Manual Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -368,7 +368,7 @@ describe('GitHub Manual Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -403,7 +403,7 @@ describe('GitHub App Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['id' => 987654321], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -430,7 +430,7 @@ describe('GitLab Manual Webhook HMAC', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], [ 'X-Gitlab-Token' => 'attacker-supplied-token', @@ -447,7 +447,7 @@ describe('GitLab Manual Webhook HMAC', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], [ 'X-Gitlab-Token' => 'wrong-token', @@ -465,7 +465,7 @@ describe('GitLab Manual Webhook HMAC', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], [ 'X-Gitlab-Token' => $secret, @@ -486,7 +486,7 @@ describe('Bitbucket Manual Webhook HMAC', function () { ]); $payload = json_encode([ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => 'test-org/test-repo'], ]); @@ -505,7 +505,7 @@ describe('Bitbucket Manual Webhook HMAC', function () { $secret = $app->manual_webhook_secret_bitbucket; $payload = json_encode([ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => 'test-org/test-repo'], ]); @@ -523,7 +523,7 @@ describe('Bitbucket Manual Webhook HMAC', function () { $app = createApplicationWithWebhook(); $payload = json_encode([ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => 'test-org/test-repo'], ]); @@ -542,7 +542,7 @@ describe('Bitbucket Manual Webhook HMAC', function () { $secret = $app->manual_webhook_secret_bitbucket; $payload = json_encode([ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => 'test-org/test-repo'], ]); @@ -571,7 +571,7 @@ describe('Gitea Manual Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -591,7 +591,7 @@ describe('Gitea Manual Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -612,7 +612,7 @@ describe('Gitea Manual Webhook HMAC', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -638,7 +638,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => ''], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -661,7 +661,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -684,7 +684,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -727,7 +727,7 @@ describe('Manual Webhook Repository Matching', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => ''], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], ['HTTP_X-Gitlab-Token' => 'wrong-token'], @@ -736,7 +736,7 @@ describe('Manual Webhook Repository Matching', function () { 'bitbucket', '/webhooks/source/bitbucket/events/manual', [ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => ''], ], ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'], @@ -747,7 +747,7 @@ describe('Manual Webhook Repository Matching', function () { [ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => ''], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'], @@ -778,7 +778,7 @@ describe('Manual Webhook Repository Matching', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], ['HTTP_X-Gitlab-Token' => 'wrong-token'], @@ -787,7 +787,7 @@ describe('Manual Webhook Repository Matching', function () { 'bitbucket', '/webhooks/source/bitbucket/events/manual', [ - 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]], 'repository' => ['full_name' => 'test-org/test'], ], ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'], @@ -798,7 +798,7 @@ describe('Manual Webhook Repository Matching', function () { [ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'], @@ -814,7 +814,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -837,7 +837,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -860,7 +860,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -885,7 +885,7 @@ describe('Manual Webhook Repository Matching', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/master', 'project' => ['path_with_namespace' => 'services/xyz'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], [ 'X-Gitlab-Token' => $secret, @@ -906,7 +906,7 @@ describe('Manual Webhook Repository Matching', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/master', 'project' => ['path_with_namespace' => 'services/xyz'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ], [ 'X-Gitlab-Token' => $secret, @@ -925,7 +925,7 @@ describe('Manual Webhook Repository Matching', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], - 'after' => 'abc123', + 'after' => 'abc1234', 'commits' => [], ]); @@ -995,7 +995,7 @@ function sendSignedManualWebhook(TestCase $test, string $provider, Application $ * * @return array */ -function manualWebhookPushPayloadWithoutCommits(string $provider, string $after = 'abc123'): array +function manualWebhookPushPayloadWithoutCommits(string $provider, string $after = 'abc1234'): array { if ($provider === 'gitlab') { return [ @@ -1070,7 +1070,7 @@ describe('Manual Webhook Push Payloads Without Commits', function () { test('a github push marked as deleted does not queue a deployment', function () { Queue::fake(); $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); - $payload = manualWebhookPushPayloadWithoutCommits('github', after: 'abc123') + ['deleted' => true, 'commits' => []]; + $payload = manualWebhookPushPayloadWithoutCommits('github', after: 'abc1234') + ['deleted' => true, 'commits' => []]; $response = sendSignedManualWebhook($this, 'github', $application, $payload); @@ -1112,7 +1112,7 @@ describe('Manual Webhook Malformed Payloads', function () { 'bitbucket branch deletion' => ['bitbucket', ['push' => ['changes' => [['new' => null, 'old' => ['name' => 'main']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], 'bitbucket with an array branch' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => ['main']]]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], 'bitbucket without repository' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main']]]]], 'Invalid repository'], - 'bitbucket with malformed commits' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']], 'commits' => [['message' => ['x']], 'text']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'Deployment queued'], + 'bitbucket with malformed commits' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']], 'commits' => [['message' => ['x']], 'text']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'Deployment queued'], ]); test('gitea deliveries for unsupported events get a clean response', function () { @@ -1158,7 +1158,7 @@ describe('App Webhook Push Payloads Without Commits', function () { $payload = json_encode([ 'ref' => 'refs/heads/main', 'repository' => ['id' => 987654321], - 'after' => 'abc123', + 'after' => 'abc1234', ]); $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ @@ -1196,7 +1196,7 @@ describe('App Webhook Push Payloads Without Commits', function () { 'object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['id' => 4242], - 'after' => 'abc123', + 'after' => 'abc1234', ]; if ($variant === 'null') { $payload['commits'] = null; @@ -1333,9 +1333,9 @@ describe('Manual Webhook Repeated Failed Deliveries', function () { $application = createApplicationWithWebhook(); for ($i = 0; $i < 30; $i++) { - sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: "commit-{$i}")->assertOk(); + sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: sprintf('abc%04d', $i))->assertOk(); } - sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: 'commit-30')->assertStatus(429); + sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: 'abc0030')->assertStatus(429); })->with(['github', 'bitbucket', 'gitea']); }); diff --git a/tests/Feature/Webhook/WebhookPayloadTypesTest.php b/tests/Feature/Webhook/WebhookPayloadTypesTest.php new file mode 100644 index 0000000000..f54c60564c --- /dev/null +++ b/tests/Feature/Webhook/WebhookPayloadTypesTest.php @@ -0,0 +1,564 @@ + 0]); +}); + +function webhookTypesSha(): string +{ + return 'e83c5163316f89bfbde7d9ab23ca2e25604af290'; +} + +/** + * An application that the handler deploys for a valid payload. App handlers + * get a GitHub App or GitLab App source. + */ +function webhookTypesApplication(string $handler): Application +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $source = []; + if ($handler === 'github app') { + $githubApp = GithubApp::create([ + 'uuid' => (string) str()->uuid(), + 'name' => 'github-app-payload-types', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'github-app-secret', + 'team_id' => $team->id, + 'is_public' => false, + ]); + $source = ['source_id' => $githubApp->id, 'source_type' => GithubApp::class, 'repository_project_id' => 987654321]; + } + if ($handler === 'gitlab app') { + $gitlabApp = GitlabApp::create([ + 'name' => 'gitlab-app-payload-types', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'gitlab-app-token', + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + $source = ['source_id' => $gitlabApp->id, 'source_type' => GitlabApp::class, 'repository_project_id' => 4242]; + } + + $application = Application::create(array_merge([ + 'name' => 'webhook-payload-types-app', + 'git_repository' => 'https://github.com/test-org/test-repo', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $source)); + $application->settings->update([ + 'is_auto_deploy_enabled' => true, + 'is_preview_deployments_enabled' => true, + ]); + + return $application->refresh(); +} + +/** + * A valid payload for the handler and event. + * + * @return array + */ +function webhookTypesPayload(string $handler, string $event, string $state = 'open'): array +{ + $sha = webhookTypesSha(); + $provider = str($handler)->before(' ')->value(); + + if ($event === 'push') { + return match ($provider) { + 'gitlab' => [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['id' => 4242, 'path_with_namespace' => 'test-org/test-repo'], + 'after' => $sha, + 'commits' => [], + ], + 'bitbucket' => [ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => $sha]]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ], + default => [ + 'ref' => 'refs/heads/main', + 'repository' => ['id' => 987654321, 'full_name' => 'test-org/test-repo'], + 'after' => $sha, + 'commits' => [], + ], + }; + } + + $closed = $state === 'closed'; + + return match ($provider) { + 'github' => [ + 'action' => $closed ? 'closed' : 'opened', + 'number' => 42, + 'repository' => ['id' => 987654321, 'full_name' => 'test-org/test-repo'], + 'pull_request' => [ + 'html_url' => 'https://github.com/test-org/test-repo/pull/42', + 'title' => 'Add feature', + 'author_association' => 'OWNER', + 'head' => ['ref' => 'feature', 'sha' => $sha, 'repo' => ['id' => 987654321, 'full_name' => 'test-org/test-repo']], + 'base' => ['ref' => 'main', 'repo' => ['id' => 987654321, 'full_name' => 'test-org/test-repo']], + ], + ], + 'gitlab' => [ + 'object_kind' => 'merge_request', + 'project' => ['id' => 4242, 'path_with_namespace' => 'test-org/test-repo'], + 'object_attributes' => [ + 'action' => $closed ? 'close' : 'open', + 'iid' => 7, + 'url' => 'https://gitlab.com/test-org/test-repo/-/merge_requests/7', + 'title' => 'Add feature', + 'source_branch' => 'feature', + 'target_branch' => 'main', + 'source_project_id' => 4242, + 'target_project_id' => 4242, + 'last_commit' => ['id' => $sha, 'message' => 'Add feature'], + ], + ], + 'gitea' => [ + 'action' => $closed ? 'closed' : 'opened', + 'number' => 7, + 'repository' => ['id' => 55, 'full_name' => 'test-org/test-repo'], + 'pull_request' => [ + 'html_url' => 'https://gitea.example.com/test-org/test-repo/pulls/7', + 'title' => 'Add feature', + 'head' => ['ref' => 'feature', 'sha' => $sha, 'repo' => ['id' => 55]], + 'base' => ['ref' => 'main', 'repo' => ['id' => 55]], + ], + ], + 'bitbucket' => [ + 'pullrequest' => [ + 'id' => 7, + 'title' => 'Add feature', + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-org/test-repo/pull-requests/7']], + 'source' => ['branch' => ['name' => 'feature'], 'commit' => ['hash' => substr($sha, 0, 12)], 'repository' => ['uuid' => '{repo-uuid}']], + 'destination' => ['branch' => ['name' => 'main'], 'repository' => ['uuid' => '{repo-uuid}']], + ], + 'repository' => ['full_name' => 'test-org/test-repo', 'uuid' => '{repo-uuid}'], + ], + }; +} + +/** + * Send a correctly signed (or token-authenticated) delivery to the handler. + * + * @param array $payload + */ +function webhookTypesSend(TestCase $test, string $handler, string $event, string $state, Application $application, array $payload): TestResponse +{ + [$provider, $mode] = explode(' ', $handler); + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $secret = match ($handler) { + 'github app' => 'github-app-secret', + 'gitlab app' => 'gitlab-app-token', + default => $application->{"manual_webhook_secret_{$provider}"}, + }; + $signature = 'sha256='.hash_hmac('sha256', $body, $secret); + $headers = match ($provider) { + 'github' => [ + 'HTTP_X-GitHub-Event' => $event === 'push' ? 'push' : 'pull_request', + 'HTTP_X-Hub-Signature-256' => $signature, + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + ], + 'gitea' => [ + 'HTTP_X-Gitea-Event' => $event === 'push' ? 'push' : 'pull_request', + 'HTTP_X-Hub-Signature-256' => $signature, + ], + 'gitlab' => ['HTTP_X-Gitlab-Token' => $secret], + 'bitbucket' => [ + 'HTTP_X-Event-Key' => $event === 'push' ? 'repo:push' : ($state === 'closed' ? 'pullrequest:fulfilled' : 'pullrequest:created'), + 'HTTP_X-Hub-Signature' => $signature, + ], + }; + $uri = $mode === 'manual' ? "/webhooks/source/{$provider}/events/manual" : "/webhooks/source/{$provider}/events"; + + return $test->call('POST', $uri, [], [], [], $headers + [ + 'REMOTE_ADDR' => '203.0.113.20', + 'CONTENT_TYPE' => 'application/json', + ], $body); +} + +/** + * Invalid values for a kind of payload field. + * + * @return array + */ +function webhookTypesInvalidValues(string $kind): array +{ + $wrongTypes = [ + 'an array' => ['x'], + 'an object' => ['key' => 'value'], + 'true' => true, + 'false' => false, + ]; + + return match ($kind) { + 'string' => $wrongTypes + ['an integer' => 123, 'a float' => 1.5], + 'required string' => $wrongTypes + ['an integer' => 123, 'null' => null], + 'branch' => $wrongTypes + ['an integer' => 123, 'null' => null], + 'sha' => $wrongTypes + [ + 'an integer' => 1234567, + 'a short sha' => 'abc12', + 'an oversized sha' => str_repeat('a', 65), + 'a non-hex sha' => 'zzzzzzzz', + 'a shell expression' => '$(id)', + 'a git option' => '--upload-pack=touch', + ], + 'id' => $wrongTypes + [ + 'null' => null, + 'zero' => 0, + 'a negative integer' => -1, + 'a float' => 1.5, + 'text' => 'abc', + 'mixed text' => '12abc', + 'an oversized integer' => 2147483648, + 'an oversized numeric string' => '99999999999999999999999', + ], + 'repository id' => $wrongTypes + ['zero' => 0, 'a negative integer' => -1, 'a float' => 1.5, 'text' => 'abc'], + 'url' => $wrongTypes + [ + 'an integer' => 123, + 'a javascript url' => 'javascript:alert(1)', + 'a relative url' => '/test-org/test-repo/pull/1', + 'a url with spaces' => 'https://example.com/a b', + 'an oversized url' => 'https://example.com/'.str_repeat('a', 300), + ], + }; +} + +/** + * Payload fields that each handler reads: [path, kind, expected message]. + * A null message means "Invalid ''". + * + * @return array> + */ +function webhookTypesFields(): array +{ + return [ + 'github manual|push|open' => [ + ['ref', 'branch', 'No branch'], + ['after', 'sha', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'github manual|pr|open' => [ + ['action', 'required string', null], + ['number', 'id', null], + ['pull_request.html_url', 'url', null], + ['pull_request.title', 'string', null], + ['pull_request.author_association', 'string', null], + ['pull_request.head.ref', 'branch', 'No branch'], + ['pull_request.base.ref', 'branch', 'No branch'], + ['pull_request.head.sha', 'sha', null], + ['after', 'sha', null], + ['before', 'sha', null], + ['pull_request.head.repo.id', 'repository id', null], + ['pull_request.base.repo.id', 'repository id', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'github manual|pr|closed' => [ + ['action', 'required string', null], + ['number', 'id', null], + ['pull_request.html_url', 'url', null], + ], + 'github app|push|open' => [ + ['repository.id', 'id', 'Nothing to do.'], + ['ref', 'branch', 'No id or branch'], + ['after', 'sha', null], + ], + 'github app|pr|open' => [ + ['repository.id', 'id', 'Nothing to do.'], + ['action', 'required string', null], + ['number', 'id', null], + ['pull_request.html_url', 'url', null], + ['pull_request.title', 'string', null], + ['pull_request.author_association', 'string', null], + ['pull_request.head.ref', 'branch', 'No id or branch'], + ['pull_request.base.ref', 'branch', 'No id or branch'], + ['pull_request.head.sha', 'sha', null], + ['after', 'sha', null], + ['before', 'sha', null], + ['pull_request.head.repo.id', 'repository id', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'github app|pr|closed' => [ + ['action', 'required string', null], + ['number', 'id', null], + ['pull_request.html_url', 'url', null], + ], + 'gitlab manual|push|open' => [ + ['object_kind', 'string', 'Event not allowed'], + ['ref', 'branch', 'No branch'], + ['after', 'sha', null], + ['project.path_with_namespace', 'required string', 'Invalid repository'], + ], + 'gitlab manual|pr|open' => [ + ['object_attributes.action', 'string', null], + ['object_attributes.iid', 'id', null], + ['object_attributes.url', 'url', null], + ['object_attributes.title', 'string', null], + ['object_attributes.source_branch', 'branch', 'No branch'], + ['object_attributes.target_branch', 'branch', 'No branch'], + ['object_attributes.last_commit.id', 'sha', null], + ], + 'gitlab manual|pr|closed' => [ + ['object_attributes.action', 'string', null], + ['object_attributes.iid', 'id', null], + ], + 'gitlab app|push|open' => [ + ['object_kind', 'string', 'Event not allowed'], + ['project.id', 'id', null], + ['ref', 'branch', 'No branch'], + ['after', 'sha', null], + ], + 'gitlab app|pr|open' => [ + ['project.id', 'id', null], + ['object_attributes.action', 'string', null], + ['object_attributes.iid', 'id', null], + ['object_attributes.url', 'url', null], + ['object_attributes.title', 'string', null], + ['object_attributes.source_branch', 'branch', 'No branch'], + ['object_attributes.target_branch', 'branch', 'No branch'], + ['object_attributes.last_commit.id', 'sha', null], + ], + 'gitlab app|pr|closed' => [ + ['object_attributes.action', 'string', null], + ['object_attributes.iid', 'id', null], + ], + 'gitea manual|push|open' => [ + ['ref', 'branch', 'No branch'], + ['after', 'sha', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'gitea manual|pr|open' => [ + ['action', 'string', null], + ['number', 'id', null], + ['pull_request.html_url', 'url', null], + ['pull_request.title', 'string', null], + ['pull_request.head.ref', 'branch', 'No branch'], + ['pull_request.base.ref', 'branch', 'No branch'], + ['head.sha', 'sha', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'gitea manual|pr|closed' => [ + ['action', 'string', null], + ['number', 'id', null], + ], + 'bitbucket manual|push|open' => [ + ['push.changes.0.new.name', 'branch', 'No branch'], + ['push.changes.0.new.target.hash', 'sha', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'bitbucket manual|pr|open' => [ + ['pullrequest.id', 'id', null], + ['pullrequest.links.html.href', 'url', null], + ['pullrequest.title', 'string', null], + ['pullrequest.destination.branch.name', 'branch', 'No branch'], + ['pullrequest.source.commit.hash', 'sha', null], + ['repository.full_name', 'required string', 'Invalid repository'], + ], + 'bitbucket manual|pr|closed' => [ + ['pullrequest.id', 'id', null], + ], + ]; +} + +/** + * @return Generator + */ +function webhookTypesInvalidDeliveries(): Generator +{ + foreach (webhookTypesFields() as $target => $fields) { + [$handler, $event, $state] = explode('|', $target); + foreach ($fields as [$path, $kind, $message]) { + foreach (webhookTypesInvalidValues($kind) as $label => $value) { + yield "{$handler} {$event} {$state}: {$path} is {$label}" => [ + $handler, $event, $state, $path, $value, $message ?? "Invalid '{$path}'", + ]; + } + } + } +} + +function webhookTypesCreatePreview(Application $application, string $handler): ApplicationPreview +{ + return ApplicationPreview::create([ + 'git_type' => str($handler)->before(' ')->value(), + 'application_id' => $application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://example.com/pull/7', + ]); +} + +describe('Webhook payload value types', function () { + test('a signed delivery with an invalid value gets a clean response and does not deploy', function (string $handler, string $event, string $state, string $path, mixed $value, string $message) { + $application = webhookTypesApplication($handler); + $closedPreview = $event === 'pr' && $state === 'closed' && ! str_starts_with($handler, 'github') + ? webhookTypesCreatePreview($application, $handler) + : null; + CleanupPreviewDeployment::shouldNotRun(); + + $payload = webhookTypesPayload($handler, $event, $state); + data_set($payload, $path, $value); + + $response = webhookTypesSend($this, $handler, $event, $state, $application, $payload); + + $response->assertOk(); + expect($response->getContent())->toContain($message) + ->not->toContain('queued'); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + expect(ApplicationPreview::query()->where('application_id', $application->id)->count())->toBe($closedPreview ? 1 : 0); + Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class); + })->with(fn (): Generator => webhookTypesInvalidDeliveries()); + + test('a valid push is deployed with its commit', function (string $handler) { + $application = webhookTypesApplication($handler); + + $response = webhookTypesSend($this, $handler, 'push', 'open', $application, webhookTypesPayload($handler, 'push')); + + $response->assertOk(); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->pluck('commit')->all()) + ->toBe([webhookTypesSha()]); + })->with(['github manual', 'github app', 'gitlab manual', 'gitlab app', 'gitea manual', 'bitbucket manual']); + + test('a valid github pull request is queued for processing', function (string $handler, string $state) { + $application = webhookTypesApplication($handler); + + $response = webhookTypesSend($this, $handler, 'pr', $state, $application, webhookTypesPayload($handler, 'pr', $state)); + + $response->assertOk(); + expect($response->getContent())->toContain('PR webhook received'); + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id + && $job->action === ($state === 'closed' ? 'closed' : 'opened') + && $job->pullRequestId === 42 + && $job->pullRequestHtmlUrl === 'https://github.com/test-org/test-repo/pull/42' + && $job->pullRequestTitle === 'Add feature' + && $job->commitSha === webhookTypesSha() + && $job->authorAssociation === 'OWNER' + && $job->fullName === 'test-org/test-repo' + && $job->isForkPullRequest === false); + })->with(['github manual', 'github app'])->with(['open', 'closed']); + + test('a github pull request with a numeric string number and sync shas is queued', function (string $handler) { + $application = webhookTypesApplication($handler); + $payload = webhookTypesPayload($handler, 'pr'); + $payload['action'] = 'synchronize'; + $payload['number'] = '42'; + $payload['before'] = str_repeat('b', 40); + $payload['after'] = webhookTypesSha(); + + webhookTypesSend($this, $handler, 'pr', 'open', $application, $payload)->assertOk(); + + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->pullRequestId === 42 + && $job->beforeSha === str_repeat('b', 40) + && $job->afterSha === webhookTypesSha()); + })->with(['github manual', 'github app']); + + test('a valid pull request opens a preview deployment', function (string $handler, string $commit) { + $application = webhookTypesApplication($handler); + + $response = webhookTypesSend($this, $handler, 'pr', 'open', $application, webhookTypesPayload($handler, 'pr')); + + $response->assertOk(); + expect(ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); + $deployment = ApplicationDeploymentQueue::query()->where('application_id', $application->id)->sole(); + expect($deployment->pull_request_id)->toBe(7) + ->and($deployment->commit)->toBe($commit); + })->with([ + 'gitlab manual' => ['gitlab manual', webhookTypesSha()], + 'gitlab app' => ['gitlab app', webhookTypesSha()], + 'gitea manual' => ['gitea manual', 'HEAD'], + 'bitbucket manual' => ['bitbucket manual', substr(webhookTypesSha(), 0, 12)], + ]); + + test('a merge request id as a numeric string is accepted', function (string $handler) { + $application = webhookTypesApplication($handler); + $payload = webhookTypesPayload($handler, 'pr'); + data_set($payload, 'object_attributes.iid', '7'); + + webhookTypesSend($this, $handler, 'pr', 'open', $application, $payload)->assertOk(); + + expect(ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); + })->with(['gitlab manual', 'gitlab app']); + + test('a pull request without a url opens a preview with an empty url', function () { + $application = webhookTypesApplication('gitlab manual'); + $payload = webhookTypesPayload('gitlab manual', 'pr'); + unset($payload['object_attributes']['url']); + + webhookTypesSend($this, 'gitlab manual', 'pr', 'open', $application, $payload)->assertOk(); + + expect(ApplicationPreview::query()->where('application_id', $application->id)->sole()->pull_request_html_url)->toBe(''); + }); + + test('an unsigned github delivery in dev mode with an invalid value gets a clean response', function (string $event, string $path) { + config()->set('app.env', 'local'); + $application = webhookTypesApplication('github manual'); + $payload = webhookTypesPayload('github manual', $event); + data_set($payload, $path, ['x']); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => $event === 'push' ? 'push' : 'pull_request', + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload, JSON_THROW_ON_ERROR)); + + $response->assertOk(); + expect($response->getContent())->toContain("Invalid '{$path}'"); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class); + })->with([ + 'push after' => ['push', 'after'], + 'pull request number' => ['pr', 'number'], + 'pull request html_url' => ['pr', 'pull_request.html_url'], + ]); + + test('a valid closed pull request cleans up the preview deployment', function (string $handler) { + $application = webhookTypesApplication($handler); + webhookTypesCreatePreview($application, $handler); + CleanupPreviewDeployment::shouldRun()->once(); + + $response = webhookTypesSend($this, $handler, 'pr', 'closed', $application, webhookTypesPayload($handler, 'pr', 'closed')); + + $response->assertOk(); + expect($response->getContent())->toContain('Preview deployment closed'); + })->with(['gitlab manual', 'gitlab app', 'gitea manual', 'bitbucket manual']); +});