fix(webhooks): reject malformed payload values without a 500

Every webhook handler (GitHub, GitLab, Gitea, Bitbucket, GitHub App,
GitLab App) now reads the fields it uses through typed readers: refs,
titles, and actions must be strings, ids positive integers, commit
SHAs 7-64 hex characters, and URLs http(s). An invalid value returns
"Nothing to do. Invalid '<field>' in the request." without a
deployment. Signature checks and the failure lockout are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-27 14:37:21 +02:00
co-authored by Claude Opus 5.5
parent 129cc97047
commit eeda90a8a4
10 changed files with 952 additions and 136 deletions
@@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* A webhook payload field has a wrong type or format.
*
* Webhook handlers catch this exception and send a clean "Nothing to do."
* response. The request does not deploy anything.
*/
class InvalidWebhookPayloadException extends Exception
{
public static function forField(string $field): self
{
return new self("Nothing to do. Invalid '{$field}' in the request.");
}
}
+13 -8
View File
@@ -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;
@@ -39,7 +40,7 @@ class Bitbucket extends Controller
// A deleted branch has no "new" state, so no branch is found.
$branch = $this->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);
}
@@ -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.
*
+15 -11
View File
@@ -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);
}
+84 -41
View File
@@ -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) {
+72 -31
View File
@@ -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')]),
];
}
}
@@ -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,
+4 -4
View File
@@ -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' => [],
]);
+39 -39
View File
@@ -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<string, mixed>
*/
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']);
});
@@ -0,0 +1,564 @@
<?php
use App\Actions\Application\CleanupPreviewDeployment;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
InstanceSettings::forceCreate(['id' => 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<string, mixed>
*/
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<string, mixed> $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<string, mixed>
*/
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 '<path>'".
*
* @return array<string, array<int, array{0: string, 1: string, 2: ?string}>>
*/
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<string, array{0: string, 1: string, 2: string, 3: string, 4: mixed, 5: string}>
*/
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']);
});