diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3d92300f1..cc7c0dbbe 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\LoadComposeFile; use App\Actions\Application\StopApplication; use App\Actions\Service\StartService; @@ -9,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\LocalFileVolume; @@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type) $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); - $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false); + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false); if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Delete( + summary: 'Delete Preview Deployment', + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'delete-preview-deployment-by-pull-request-id', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'pull_request_id', + in: 'path', + description: 'Pull request ID of the preview to delete.', + required: true, + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', type: 'string')], + )), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_preview_by_pull_request_id(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('delete', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + $pullRequestId = (int) $pullRequestIdRaw; + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pullRequestId) + ->first(); + + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $preview->delete(); + CleanupPreviewDeployment::run($application, $pullRequestId, $preview); + + return response()->json(['message' => 'Preview deletion request queued.']); + } } diff --git a/openapi.json b/openapi.json index e4e03c99d..d83b30d80 100644 --- a/openapi.json +++ b/openapi.json @@ -3788,6 +3788,70 @@ ] } }, + "\/applications\/{uuid}\/previews\/{pull_request_id}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Preview Deployment", + "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", + "operationId": "delete-preview-deployment-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "description": "Pull request ID of the preview to delete.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Preview deletion queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index f2761de59..aab408098 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2398,6 +2398,48 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/previews/{pull_request_id}': + delete: + tags: + - Applications + summary: 'Delete Preview Deployment' + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' + operationId: delete-preview-deployment-by-pull-request-id + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: pull_request_id + in: path + description: 'Pull request ID of the preview to delete.' + required: true + schema: + type: integer + responses: + '200': + description: 'Preview deletion queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: diff --git a/routes/api.php b/routes/api.php index 0d3edcced..6d48fbe74 100644 --- a/routes/api.php +++ b/routes/api.php @@ -129,6 +129,8 @@ Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']); @@ -218,7 +220,7 @@ try { $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); - } catch (\Exception $e) { + } catch (Exception $e) { return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php new file mode 100644 index 000000000..bc405d48b --- /dev/null +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -0,0 +1,132 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + CleanupPreviewDeployment::shouldRun()->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); +}); + +function previewAuthHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function createTeamApiToken(User $user, Team $team, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function createPreview(Application $application, int $pullRequestId): ApplicationPreview +{ + return ApplicationPreview::create([ + 'uuid' => (string) new Cuid2, + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}", + 'fqdn' => "pr-{$pullRequestId}.example.com", + ]); +} + +describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () { + test('returns 401 when no bearer token provided', function () { + $response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42"); + + $response->assertUnauthorized(); + }); + + test('returns 404 when application uuid does not exist', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42'); + + $response->assertNotFound() + ->assertJson(['message' => 'Application not found.']); + }); + + test('returns 404 when preview does not exist for the application', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999"); + + $response->assertNotFound() + ->assertJson(['message' => 'Preview not found.']); + }); + + test('returns 422 when pull_request_id is not a positive integer', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0"); + + $response->assertStatus(422) + ->assertJson(['message' => 'Invalid pull_request_id.']); + }); + + test('soft-deletes the preview and returns 200 on success', function () { + $preview = createPreview($this->application, 42); + + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42"); + + $response->assertOk() + ->assertJson(['message' => 'Preview deletion request queued.']); + + expect($preview->fresh()->trashed())->toBeTrue(); + }); + + test('returns 403 when token lacks write ability', function () { + $readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']); + createPreview($this->application, 7); + + $response = $this->withHeaders(previewAuthHeaders($readOnlyToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7"); + + $response->assertForbidden(); + }); +});