fix(api): accept github_app_uuid when updating an application

Closes #12016
This commit is contained in:
Andras Bacsai
2026-10-15 22:49:32 +02:00
parent f8eb6f725e
commit 7553608a3c
4 changed files with 129 additions and 33 deletions
@@ -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') ?? []);
-16
View File
@@ -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": [
-12
View File
@@ -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]
@@ -0,0 +1,103 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => 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);
});