diff --git a/app/Http/Controllers/CreateProjectController.php b/app/Http/Controllers/CreateProjectController.php new file mode 100644 index 0000000000..e6fd4cf30c --- /dev/null +++ b/app/Http/Controllers/CreateProjectController.php @@ -0,0 +1,20 @@ +vue('create-project-page', 'Create Project', [ + 'max-project-visibility' => $request->user()->admin ?? false ? 'PUBLIC' : config('cdash.max_project_visibility'), + ]); + } +} diff --git a/app/cdash/app/Model/Project.php b/app/cdash/app/Model/Project.php index 713b903bf9..0a22968019 100644 --- a/app/cdash/app/Model/Project.php +++ b/app/cdash/app/Model/Project.php @@ -114,7 +114,7 @@ public function Save(): bool $project = EloquentProject::findOrNew($this->Id); $project->fill([ 'name' => $this->Name ?? '', - 'description' => $this->Description ?? '', + 'description' => $this->Description, 'homeurl' => $this->HomeUrl ?? '', 'cvsurl' => $this->CvsUrl ?? '', 'documentationurl' => $this->DocumentationUrl ?? '', diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index e8ba0debca..4ae7a27844 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -332,6 +332,8 @@ add_browser_test(/Browser/Pages/ProjectMembersPageTest) add_browser_test(/Browser/Pages/ProjectBuildsPageTest) +add_browser_test(/Browser/Pages/CreateProjectPageTest) + add_browser_test(/Browser/Pages/ProjectsPageTest) set_property(TEST /Browser/Pages/ProjectsPageTest APPEND PROPERTY RUN_SERIAL TRUE) diff --git a/config/cdash.php b/config/cdash.php index 55014656f5..2ab98b606e 100755 --- a/config/cdash.php +++ b/config/cdash.php @@ -61,6 +61,7 @@ 'allow_submit_only_tokens' => env('ALLOW_SUBMIT_ONLY_TOKENS', true), 'unlimited_projects' => $unlimited_projects, 'user_create_projects' => env('USER_CREATE_PROJECTS', false), + // Options: PUBLIC, PROTECTED, PRIVATE // Defaults to public. Only meaningful if USER_CREATE_PROJECT=true. 'max_project_visibility' => env('MAX_PROJECT_VISIBILITY', 'PUBLIC'), 'require_authenticated_submissions' => env('REQUIRE_AUTHENTICATED_SUBMISSIONS', false), diff --git a/database/migrations/2026_01_18_161331_nullable_project_description.php b/database/migrations/2026_01_18_161331_nullable_project_description.php new file mode 100644 index 0000000000..b15995f02d --- /dev/null +++ b/database/migrations/2026_01_18_161331_nullable_project_description.php @@ -0,0 +1,16 @@ + import('./components/Bui const BuildCommandsPage = Vue.defineAsyncComponent(() => import('./components/BuildCommandsPage.vue')); const CoverageFilePage = Vue.defineAsyncComponent(() => import('./components/CoverageFilePage.vue')); const BuildCoveragePage = Vue.defineAsyncComponent(() => import('./components/BuildCoveragePage.vue')); +const CreateProjectPage = Vue.defineAsyncComponent(() => import('./components/CreateProjectPage.vue')); const cdash_components = { BuildConfigure, @@ -59,6 +60,7 @@ const cdash_components = { BuildCommandsPage, CoverageFilePage, BuildCoveragePage, + CreateProjectPage, }; const app = Vue.createApp({ diff --git a/resources/js/vue/components/CreateProjectPage.vue b/resources/js/vue/components/CreateProjectPage.vue new file mode 100644 index 0000000000..d8a9d57184 --- /dev/null +++ b/resources/js/vue/components/CreateProjectPage.vue @@ -0,0 +1,239 @@ + + + + + + {{ fatalError }} + + + + Project Name + + + + {{ validationErrors.name[0] }} + + + + + Description + + + + {{ validationErrors.description[0] }} + + + + + Visibility + + + + + + + Public + + + Does not require authentication to access. + + + + + + + + Protected + + + Access limited to authenticated users. + + + + + + + + Private + + + Requires access to be granted explicitly. + + + + + + + + Submission Authentication + + + + + Require submissions to provide a valid authentication token. + + + + + + Create Project + + + + + + + diff --git a/routes/web.php b/routes/web.php index 5c6b93dfc2..5e6655a9e7 100755 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ */ use App\Http\Controllers\CoverageFileController; +use App\Http\Controllers\CreateProjectController; use App\Http\Controllers\GlobalInvitationController; use App\Http\Controllers\ProjectInvitationController; use App\Models\DynamicAnalysis; @@ -163,7 +164,7 @@ ->whereNumber('id'); Route::permanentRedirect('/project/{id}/edit', url('/projects/{id}/edit')); -Route::get('/projects/new', 'EditProjectController@create'); +Route::get('/projects/new', CreateProjectController::class); Route::permanentRedirect('/project/new', url('/projects/new')); Route::get('/projects/{id}/testmeasurements', 'ManageMeasurementsController@show') diff --git a/tests/Browser/Pages/CreateProjectPageTest.php b/tests/Browser/Pages/CreateProjectPageTest.php new file mode 100644 index 0000000000..d07c2e874b --- /dev/null +++ b/tests/Browser/Pages/CreateProjectPageTest.php @@ -0,0 +1,164 @@ + + */ + private array $users = []; + + /** + * @var array + */ + private array $projects = []; + + public function tearDown(): void + { + foreach ($this->users as $user) { + $user->delete(); + } + $this->users = []; + + foreach ($this->projects as $project) { + $project->delete(); + } + $this->projects = []; + + parent::tearDown(); + } + + public function testReturns403WhenInsufficientPermissions(): void + { + $this->users['normal'] = $this->makeNormalUser(); + $this->users['admin'] = $this->makeAdminUser(); + + $this->browse(function (Browser $browser): void { + $browser->visit('/projects/new') + ->assertSee('This action is unauthorized.') + ; + + $browser->loginAs($this->users['normal']) + ->visit('/projects/new') + ->assertSee('This action is unauthorized.') + ; + + $browser->loginAs($this->users['admin']) + ->visit('/projects/new') + ->assertDontSee('This action is unauthorized.') + ; + }); + } + + public function testShowsErrorWhenInvalidProjectNameProvided(): void + { + $this->users['admin'] = $this->makeAdminUser(); + + $this->browse(function (Browser $browser): void { + $browser->loginAs($this->users['admin']) + ->visit('/projects/new') + ->waitFor('@create-project-page') + ->type('@project-name-input', 'invalid project name %') + ->click('@create-project-button') + ->waitFor('@project-name-validation-errors') + ->assertSeeIn('@project-name-validation-errors', 'Project name may only contain letters, numbers, dashes, and underscores.') + ; + }); + } + + public function testCreateProjectSetDescription(): void + { + $this->users['admin'] = $this->makeAdminUser(); + + $name = Str::uuid()->toString(); + $description = Str::uuid()->toString(); + + $this->browse(function (Browser $browser) use ($description, $name): void { + $browser->loginAs($this->users['admin']) + ->visit('/projects/new') + ->waitFor('@create-project-page') + ->type('@project-name-input', $name) + ->type('@project-description-input', $description) + ->click('@create-project-button') + ->waitForReload() + ; + }); + + $project = Project::where('name', $name)->firstOrFail(); + $this->projects[] = $project; + + self::assertSame($description, $project->description); + } + + public function testCreateProjectSetAuthenticatedSubmissions(): void + { + $this->users['admin'] = $this->makeAdminUser(); + + $name = Str::uuid()->toString(); + + $this->browse(function (Browser $browser) use ($name): void { + $browser->loginAs($this->users['admin']) + ->visit('/projects/new') + ->waitFor('@create-project-page') + ->type('@project-name-input', $name) + ->click('@project-authenticated-submissions-input') + ->click('@create-project-button') + ->waitForReload() + ; + }); + + $project = Project::where('name', $name)->firstOrFail(); + $this->projects[] = $project; + + self::assertTrue($project->authenticatesubmissions); + } + + /** + * @return array> + */ + public static function visibilities(): array + { + return [ + ['public', Project::ACCESS_PUBLIC], + ['protected', Project::ACCESS_PROTECTED], + ['private', Project::ACCESS_PRIVATE], + ]; + } + + #[DataProvider('visibilities')] + public function testCreateProjectSetVisibility(string $fieldname, int $role): void + { + $this->users['admin'] = $this->makeAdminUser(); + + $name = Str::uuid()->toString(); + + $this->browse(function (Browser $browser) use ($fieldname, $name): void { + $browser->loginAs($this->users['admin']) + ->visit('/projects/new') + ->waitFor('@create-project-page') + ->type('@project-name-input', $name) + ->click('@project-visibility-' . $fieldname) + ->click('@create-project-button') + ->waitForReload() + ; + }); + + $project = Project::where('name', $name)->firstOrFail(); + $this->projects[] = $project; + + self::assertSame($role, $project->public); + } +}