diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php index 83247821a9..9c1fb558c8 100644 --- a/app/Livewire/Project/New/GiteaPrivateRepository.php +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -170,10 +170,12 @@ class GiteaPrivateRepository extends Component 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_path' => 'required|string', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { @@ -181,7 +183,7 @@ class GiteaPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 43c3732464..dacfbe4881 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -209,11 +209,13 @@ class GithubPrivateRepository extends Component 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 2a68a7770d..2a74efc50b 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -40,9 +40,9 @@ class GithubPrivateRepositoryDeployKey extends Component public ?string $publish_directory = null; - // In case of docker compose - public ?string $base_directory = null; + public ?string $base_directory = '/'; + // In case of docker compose public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -71,6 +71,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'base_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), ]; } @@ -82,6 +83,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'Is static', 'publish_directory' => 'Publish directory', 'build_pack' => 'Build pack', + 'base_directory' => 'Base directory', ]; public function mount() @@ -181,6 +183,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'destination_id' => $destination->id, 'destination_type' => $destination_class, 'private_key_id' => $privateKey->id, + 'base_directory' => $this->base_directory, ]; } else { $application_init = [ @@ -196,6 +199,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'private_key_id' => $privateKey->id, 'source_id' => $this->git_source->id, 'source_type' => $this->git_source->getMorphClass(), + 'base_directory' => $this->base_directory, ]; } if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { @@ -206,7 +210,6 @@ class GithubPrivateRepositoryDeployKey extends Component } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; - $application_init['base_directory'] = $this->base_directory; } $application = new Application($application_init); $application->save(); diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 3da796a81e..a11fc69c01 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -167,10 +167,12 @@ class GitlabPrivateRepository extends Component 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_path' => 'required|string', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { @@ -178,7 +180,7 @@ class GitlabPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 6c17b2810a..3ea109b4a0 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -73,7 +73,7 @@ class PublicGitRepository extends Component 'isStatic' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'base_directory' => 'nullable|string', + 'base_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; @@ -352,7 +352,6 @@ class PublicGitRepository extends Component } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; - $application_init['base_directory'] = $this->base_directory; } $application = new Application($application_init); $application->save(); diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php index 59be95e55c..4dc81bc4b4 100644 --- a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php +++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php @@ -113,6 +113,51 @@ test('deploy key application keeps an scp-style ssh url with a custom username', ->toBe('custom-user@github.example.com:organization/repository.git'); }); +test('deploy key application keeps the base directory for every build pack', function (string $buildPack) { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/monorepo.git') + ->set('branch', 'main') + ->set('build_pack', $buildPack) + ->set('base_directory', '/apps/api') + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); +})->with(['dockerfile', 'railpack', 'static', 'dockercompose']); + +test('deploy key application uses the repository root when the base directory is empty', function () { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/repository.git') + ->set('branch', 'main') + ->set('build_pack', 'dockerfile') + ->set('base_directory', '') + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->base_directory)->toBe('/'); +}); + +test('deploy key application rejects an invalid base directory', function () { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/repository.git') + ->set('branch', 'main') + ->set('build_pack', 'dockerfile') + ->set('base_directory', '/apps; rm -rf /') + ->call('submit') + ->assertHasErrors('base_directory'); + + expect(Application::query()->count())->toBe(0); +}); + test('member cannot invoke the deploy key selection action directly', function () { actAsDeployKeyUser($this->member, $this->team); $this->withoutExceptionHandling(); diff --git a/tests/Feature/NewApplicationRepositoryFlowsTest.php b/tests/Feature/NewApplicationRepositoryFlowsTest.php new file mode 100644 index 0000000000..bc0eb08165 --- /dev/null +++ b/tests/Feature/NewApplicationRepositoryFlowsTest.php @@ -0,0 +1,185 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'name' => 'dest-flows', + 'network' => 'coolify-flows', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function repositoryFlowTarget(Testable $component): Testable +{ + return $component + ->set('parameters', ['project_uuid' => test()->project->uuid, 'environment_uuid' => test()->environment->uuid]) + ->set('query', ['destination' => test()->destination->uuid]); +} + +function publicRepositoryFlow(): Testable +{ + return repositoryFlowTarget(Livewire::test(PublicGitRepository::class, ['type' => 'public'])) + ->set('repository_url', 'https://github.com/acme/web') + ->set('git_repository', 'https://github.com/acme/web') + ->set('git_branch', 'main'); +} + +function githubAppRepositoryFlow(): Testable +{ + openssl_pkey_export(openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]), $pemKey); + $githubApp = GithubApp::create([ + 'name' => 'GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'webhook_secret' => 'webhook-secret', + 'private_key_id' => PrivateKey::create(['name' => 'Key', 'private_key' => $pemKey, 'team_id' => test()->team->id])->id, + 'team_id' => test()->team->id, + 'is_system_wide' => false, + ]); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/67890/access_tokens' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/installation/repositories*' => Http::response([ + 'total_count' => 1, + 'repositories' => [['id' => 1, 'name' => 'web', 'owner' => ['login' => 'acme']]], + ], 200), + ]); + + return repositoryFlowTarget(Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])) + ->call('loadRepositories', $githubApp->id) + ->set('selected_repository_owner', 'acme') + ->set('selected_repository_repo', 'web') + ->set('selected_branch_name', 'main'); +} + +function gitlabRepositoryFlow(): Testable +{ + $gitlabApp = GitlabApp::create([ + 'name' => 'GitLab', + 'api_url' => 'https://1.1.1.1/api/v4', + 'html_url' => 'https://1.1.1.1', + 'custom_user' => 'git', + 'custom_port' => 22, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'webhook_token' => 'webhook-token', + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => test()->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + Http::fake(['1.1.1.1/api/v4/*' => Http::response([['name' => 'main']], 200)]); + + return repositoryFlowTarget(Livewire::test(GitlabPrivateRepository::class, ['type' => 'private-gitlab-app'])) + ->set('gitlab_app_id', $gitlabApp->id) + ->set('selected_project_id', 42) + ->set('selected_repository_path', 'acme/web') + ->set('selected_branch_name', 'main'); +} + +function giteaRepositoryFlow(): Testable +{ + // Http::GitSource() pins DNS before Http::fake() answers, so use an IP literal. + $giteaApp = GiteaApp::create([ + 'name' => 'Gitea', + 'api_url' => 'https://1.1.1.1/api/v1', + 'html_url' => 'https://1.1.1.1', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => test()->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + Http::fake([ + '1.1.1.1/api/v1/user/repos*' => Http::response([ + ['id' => 42, 'name' => 'web', 'full_name' => 'acme/web', 'default_branch' => 'main', 'html_url' => 'https://1.1.1.1/acme/web', 'owner' => ['login' => 'acme']], + ], 200), + ]); + + return repositoryFlowTarget(Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app'])) + ->call('loadRepositories', $giteaApp->id) + ->set('selected_branch_name', 'main'); +} + +dataset('repository flows', [ + 'public repository' => 'publicRepositoryFlow', + 'github app' => 'githubAppRepositoryFlow', + 'gitlab' => 'gitlabRepositoryFlow', + 'gitea' => 'giteaRepositoryFlow', +]); + +test('repository flows keep the entered base directory', function (string $flow) { + $flow() + ->set('build_pack', 'dockerfile') + ->set('base_directory', '/apps/api') + ->call('submit'); + + expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); +})->with('repository flows'); + +test('repository flows reject an invalid base directory', function (string $flow) { + $flow() + ->set('build_pack', 'dockerfile') + ->set('base_directory', '/apps; rm -rf /') + ->call('submit'); + + expect(Application::query()->count())->toBe(0); +})->with('repository flows'); + +test('repository flows do not create an application on a build-only server', function (string $flow) { + $this->server->settings->update(['server_role' => ServerRole::BUILD]); + Server::flushIdentityMap(); + + $flow()->call('submit'); + + expect(Application::query()->count())->toBe(0); +})->with('repository flows');