diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0dcb024cad..6493ff7b47 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3063,14 +3063,10 @@ class ApplicationsController extends Controller schema: new OA\Schema( type: 'object', properties: [ - 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], - 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], - 'environment_name' => ['type' => 'string', 'description' => 'The environment name.'], 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], - 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], @@ -3262,10 +3258,11 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', + 'github_app_uuid' => 'string', 'description' => 'string|nullable', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -3342,6 +3339,24 @@ class ApplicationsController extends Controller ], 422); } + $githubApp = null; + if ($request->has('github_app_uuid')) { + if ($application->build_pack === 'dockerimage' || $application->deploymentType() === 'deploy_key') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'github_app_uuid' => 'The github_app_uuid can only be changed for applications deployed from a GitHub App or public repository.', + ], + ], 422); + } + $githubApp = GithubApp::where('uuid', $request->github_app_uuid) + ->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true)) + ->first(); + if (! $githubApp?->isConnected()) { + return response()->json(['message' => 'Github App not found.'], 404); + } + } + $applicationSettings = $this->applicationSettingsFromRequest($request); if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { return $prefixValidation; @@ -3595,6 +3610,12 @@ class ApplicationsController extends Controller $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); $application->fill($data); + if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) { + $application->source_id = $githubApp->id; + $application->source_type = $githubApp->getMorphClass(); + // The next deployment fetches the repository ID from the new source. + $application->repository_project_id = null; + } if ($requestHasNoindexDomains) { // Must run after fqdn is filled: flags are kept only for domains the app still has. $application->setNoindexDomains($request->input('noindex_domains') ?? []); diff --git a/openapi.json b/openapi.json index 4f221bdb1d..37a8275d34 100644 --- a/openapi.json +++ b/openapi.json @@ -3416,18 +3416,6 @@ "application\/json": { "schema": { "properties": { - "project_uuid": { - "type": "string", - "description": "The project UUID." - }, - "server_uuid": { - "type": "string", - "description": "The server UUID." - }, - "environment_name": { - "type": "string", - "description": "The environment name." - }, "github_app_uuid": { "type": "string", "description": "The Github App UUID." @@ -3444,10 +3432,6 @@ "type": "string", "description": "The ports to expose." }, - "destination_uuid": { - "type": "string", - "description": "The destination UUID." - }, "build_pack": { "type": "string", "enum": [ diff --git a/openapi.yaml b/openapi.yaml index 41f74e13f9..78d482b23c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2239,15 +2239,6 @@ paths: application/json: schema: properties: - project_uuid: - type: string - description: 'The project UUID.' - server_uuid: - type: string - description: 'The server UUID.' - environment_name: - type: string - description: 'The environment name.' github_app_uuid: type: string description: 'The Github App UUID.' @@ -2260,9 +2251,6 @@ paths: ports_exposes: type: string description: 'The ports to expose.' - destination_uuid: - type: string - description: 'The destination UUID.' build_pack: type: string enum: [nixpacks, railpack, static, dockerfile, dockercompose] diff --git a/tests/Feature/ApplicationGithubAppSourceApiTest.php b/tests/Feature/ApplicationGithubAppSourceApiTest.php new file mode 100644 index 0000000000..fa55f6305e --- /dev/null +++ b/tests/Feature/ApplicationGithubAppSourceApiTest.php @@ -0,0 +1,103 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('github-app-source-test', ['*'])->plainTextToken; + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->oldGithubApp = githubAppSourceApiApp($this->team->id, 'org-a'); + $this->newGithubApp = githubAppSourceApiApp($this->team->id, 'org-b'); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'git_repository' => 'org-a/repository', + 'source_id' => $this->oldGithubApp->id, + 'source_type' => GithubApp::class, + 'repository_project_id' => 111, + 'private_key_id' => null, + ]); +}); + +function githubAppSourceApiApp(int $teamId, string $name, array $overrides = []): GithubApp +{ + return GithubApp::create(array_merge([ + 'name' => $name, + 'team_id' => $teamId, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'app_id' => 1, + 'installation_id' => 1, + ], $overrides)); +} + +function patchApplicationSource(array $payload) +{ + return test()->withHeaders(['Authorization' => 'Bearer '.test()->token]) + ->patchJson('/api/v1/applications/'.test()->application->uuid, $payload); +} + +test('PATCH moves the application to another GitHub App of the team', function () { + patchApplicationSource([ + 'github_app_uuid' => $this->newGithubApp->uuid, + 'git_repository' => 'org-b/repository', + 'git_branch' => 'main', + ])->assertOk(); + + $application = $this->application->refresh(); + expect($application->source_id)->toBe($this->newGithubApp->id) + ->and($application->source_type)->toBe(GithubApp::class) + ->and($application->git_repository)->toBe('org-b/repository') + ->and($application->repository_project_id)->toBeNull(); +}); + +test('PATCH rejects a GitHub App of another team', function () { + $otherTeam = Team::factory()->create(); + $foreignGithubApp = githubAppSourceApiApp($otherTeam->id, 'foreign'); + + patchApplicationSource(['github_app_uuid' => $foreignGithubApp->uuid])->assertNotFound(); + + expect($this->application->refresh()->source_id)->toBe($this->oldGithubApp->id); +}); + +test('PATCH rejects a GitHub App that is not installed', function () { + $notInstalled = githubAppSourceApiApp($this->team->id, 'not-installed', ['installation_id' => null]); + + patchApplicationSource(['github_app_uuid' => $notInstalled->uuid])->assertNotFound(); + + expect($this->application->refresh()->source_id)->toBe($this->oldGithubApp->id); +}); + +test('PATCH rejects github_app_uuid for deploy key applications', function () { + $this->application->forceFill(['private_key_id' => 1])->saveQuietly(); + + patchApplicationSource(['github_app_uuid' => $this->newGithubApp->uuid]) + ->assertUnprocessable() + ->assertInvalid(['github_app_uuid']); + + expect($this->application->refresh()->source_id)->toBe($this->oldGithubApp->id); +});