diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index e06a14e0bf..489de7e5c5 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -84,7 +84,11 @@ class Bitbucket extends Controller $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch); // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token); - $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); + $applications = Application::query(); + if ($x_bitbucket_event !== 'pullrequest:rejected' && $x_bitbucket_event !== 'pullrequest:fulfilled') { + $applications->where('git_branch', $branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index b3c0b22a48..6868ae8fac 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -84,7 +84,10 @@ class Gitea extends Controller } } if ($x_gitea_event === 'pull_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index c135aef328..3d4d22cd85 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -231,7 +231,10 @@ class Gitlab extends Controller 'skip_deploy' => $skip_deploy_pr, ] = $this->readMergeRequestPayload($payload); - $applications = $applications->where('git_branch', $base_branch)->get(); + if (! in_array($action, ['closed', 'close', 'merge'])) { + $applications->where('git_branch', $base_branch); + } + $applications = $applications->get(); foreach ($applications as $application) { if (! $application->destination->server->isFunctional()) { @@ -440,7 +443,10 @@ class Gitlab extends Controller } } if ($x_gitlab_event === 'merge_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if (! in_array($action, ['closed', 'close', 'merge'])) { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } diff --git a/tests/Feature/BitbucketPullRequestWebhookRoutingTest.php b/tests/Feature/BitbucketPullRequestWebhookRoutingTest.php new file mode 100644 index 0000000000..f186c30c36 --- /dev/null +++ b/tests/Feature/BitbucketPullRequestWebhookRoutingTest.php @@ -0,0 +1,105 @@ +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(); + + return Application::create([ + 'name' => 'bitbucket-pr-webhook-app', + 'git_repository' => 'https://bitbucket.org/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(), + ]); +} + +function sendBitbucketPullRequestWebhook(Application $application, string $event, string $destinationBranch) +{ + $body = json_encode([ + 'repository' => [ + 'full_name' => 'test-org/test-repo', + 'uuid' => '{repository-uuid}', + ], + 'pullrequest' => [ + 'id' => 42, + 'title' => 'Stacked change', + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-org/test-repo/pull-requests/42']], + 'source' => [ + 'branch' => ['name' => 'feature/child'], + 'commit' => ['hash' => 'abc1234def5678'], + 'repository' => ['uuid' => '{repository-uuid}'], + ], + 'destination' => [ + 'branch' => ['name' => $destinationBranch], + 'repository' => ['uuid' => '{repository-uuid}'], + ], + ], + ], JSON_THROW_ON_ERROR); + + return test()->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => $event, + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_bitbucket), + 'CONTENT_TYPE' => 'application/json', + ], $body); +} + +it('cleans up the preview of a closed pull request after the destination branch changes', function (string $event) { + $application = createBitbucketPullRequestWebhookApplication(); + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://bitbucket.org/test-org/test-repo/pull-requests/42', + 'git_type' => 'bitbucket', + ]); + + CleanupPreviewDeployment::shouldRun() + ->once() + ->withArgs(fn (Application $cleanedApplication, int $pullRequestId, ApplicationPreview $cleanedPreview): bool => $cleanedApplication->is($application) + && $pullRequestId === 42 + && $cleanedPreview->is($preview)) + ->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); + + $response = sendBitbucketPullRequestWebhook($application, $event, 'feature/parent'); + + $response->assertOk(); + expect($response->getContent())->toContain('Preview deployment closed.'); +})->with(['pullrequest:fulfilled', 'pullrequest:rejected']); + +it('continues to filter opened pull requests by destination branch', function () { + Queue::fake(); + $application = createBitbucketPullRequestWebhookApplication(); + $application->settings->update(['is_preview_deployments_enabled' => true]); + + $response = sendBitbucketPullRequestWebhook($application, 'pullrequest:created', 'feature/parent'); + + $response->assertOk(); + expect(ApplicationPreview::where('application_id', $application->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/GiteaPullRequestWebhookRoutingTest.php b/tests/Feature/GiteaPullRequestWebhookRoutingTest.php new file mode 100644 index 0000000000..841b5d577c --- /dev/null +++ b/tests/Feature/GiteaPullRequestWebhookRoutingTest.php @@ -0,0 +1,106 @@ +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(); + + return Application::create([ + 'name' => 'gitea-pr-webhook-app', + 'git_repository' => 'https://gitea.example.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(), + ]); +} + +function sendGiteaPullRequestWebhook(Application $application, string $action, string $baseBranch) +{ + $body = json_encode([ + 'action' => $action, + 'number' => 42, + 'repository' => [ + 'id' => 987654321, + 'full_name' => 'test-org/test-repo', + ], + 'pull_request' => [ + 'html_url' => 'https://gitea.example.com/test-org/test-repo/pulls/42', + 'title' => 'Stacked change', + 'head' => [ + 'ref' => 'feature/child', + 'sha' => 'abc1234def5678', + 'repo' => ['id' => 987654321], + ], + 'base' => [ + 'ref' => $baseBranch, + 'repo' => ['id' => 987654321], + ], + ], + ], JSON_THROW_ON_ERROR); + + return test()->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_gitea), + 'CONTENT_TYPE' => 'application/json', + ], $body); +} + +it('cleans up the preview of a closed pull request after the base branch changes', function () { + $application = createGiteaPullRequestWebhookApplication(); + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://gitea.example.com/test-org/test-repo/pulls/42', + 'git_type' => 'gitea', + ]); + + CleanupPreviewDeployment::shouldRun() + ->once() + ->withArgs(fn (Application $cleanedApplication, int $pullRequestId, ApplicationPreview $cleanedPreview): bool => $cleanedApplication->is($application) + && $pullRequestId === 42 + && $cleanedPreview->is($preview)) + ->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); + + $response = sendGiteaPullRequestWebhook($application, 'closed', 'feature/parent'); + + $response->assertOk(); + expect($response->getContent())->toContain('Preview deployment closed.'); +}); + +it('continues to filter opened pull requests by base branch', function () { + Queue::fake(); + $application = createGiteaPullRequestWebhookApplication(); + $application->settings->update(['is_preview_deployments_enabled' => true]); + + $response = sendGiteaPullRequestWebhook($application, 'opened', 'feature/parent'); + + $response->assertOk(); + expect(ApplicationPreview::where('application_id', $application->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/GitlabMergeRequestWebhookRoutingTest.php b/tests/Feature/GitlabMergeRequestWebhookRoutingTest.php new file mode 100644 index 0000000000..4f55c86e4a --- /dev/null +++ b/tests/Feature/GitlabMergeRequestWebhookRoutingTest.php @@ -0,0 +1,155 @@ +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(); + + return Application::create(array_merge([ + 'name' => 'gitlab-mr-webhook-app', + 'git_repository' => 'https://gitlab.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(), + ], $overrides)); +} + +function createGitlabMergeRequestWebhookSource(): GitlabApp +{ + return GitlabApp::create([ + 'name' => 'gitlab-app-webhook-test', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'webhook_token' => 'test-webhook-token', + 'team_id' => Team::factory()->create()->id, + ]); +} + +function gitlabMergeRequestPayload(string $action, string $targetBranch): array +{ + return [ + 'object_kind' => 'merge_request', + 'project' => [ + 'id' => 987654321, + 'path_with_namespace' => 'test-org/test-repo', + ], + 'object_attributes' => [ + 'action' => $action, + 'iid' => 42, + 'url' => 'https://gitlab.com/test-org/test-repo/-/merge_requests/42', + 'title' => 'Stacked change', + 'source_branch' => 'feature/child', + 'target_branch' => $targetBranch, + 'source_project_id' => 987654321, + 'target_project_id' => 987654321, + 'last_commit' => [ + 'id' => 'abc1234def5678', + 'message' => 'Stacked change', + ], + ], + ]; +} + +function expectGitlabPreviewCleanup(Application $application): void +{ + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://gitlab.com/test-org/test-repo/-/merge_requests/42', + 'git_type' => 'gitlab', + ]); + + CleanupPreviewDeployment::shouldRun() + ->once() + ->withArgs(fn (Application $cleanedApplication, int $pullRequestId, ApplicationPreview $cleanedPreview): bool => $cleanedApplication->is($application) + && $pullRequestId === 42 + && $cleanedPreview->is($preview)) + ->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); +} + +it('cleans up the preview of a closed GitLab App merge request after the target branch changes', function (string $action) { + $gitlabApp = createGitlabMergeRequestWebhookSource(); + $application = createGitlabMergeRequestWebhookApplication([ + 'repository_project_id' => 987654321, + 'source_id' => $gitlabApp->id, + 'source_type' => GitlabApp::class, + ]); + expectGitlabPreviewCleanup($application); + + $response = $this->postJson('/webhooks/source/gitlab/events', gitlabMergeRequestPayload($action, 'feature/parent'), [ + 'X-Gitlab-Token' => 'test-webhook-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Preview deployment closed.'); +})->with(['close', 'merge']); + +it('cleans up the preview of a closed manual merge request after the target branch changes', function (string $action) { + $application = createGitlabMergeRequestWebhookApplication(); + expectGitlabPreviewCleanup($application); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', gitlabMergeRequestPayload($action, 'feature/parent'), [ + 'X-Gitlab-Event' => 'Merge Request Hook', + 'X-Gitlab-Token' => $application->manual_webhook_secret_gitlab, + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Preview deployment closed.'); +})->with(['close', 'merge']); + +it('continues to filter opened merge requests by target branch', function (string $endpoint) { + Queue::fake(); + + if ($endpoint === 'app') { + $gitlabApp = createGitlabMergeRequestWebhookSource(); + $application = createGitlabMergeRequestWebhookApplication([ + 'repository_project_id' => 987654321, + 'source_id' => $gitlabApp->id, + 'source_type' => GitlabApp::class, + ]); + $application->settings->update(['is_preview_deployments_enabled' => true]); + + $response = $this->postJson('/webhooks/source/gitlab/events', gitlabMergeRequestPayload('open', 'feature/parent'), [ + 'X-Gitlab-Token' => 'test-webhook-token', + ]); + } else { + $application = createGitlabMergeRequestWebhookApplication(); + $application->settings->update(['is_preview_deployments_enabled' => true]); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', gitlabMergeRequestPayload('open', 'feature/parent'), [ + 'X-Gitlab-Event' => 'Merge Request Hook', + 'X-Gitlab-Token' => $application->manual_webhook_secret_gitlab, + ]); + } + + $response->assertOk(); + expect(ApplicationPreview::where('application_id', $application->id)->exists())->toBeFalse(); +})->with(['app', 'manual']);