diff --git a/app/GraphQL/Mutations/CreateRepository.php b/app/GraphQL/Mutations/CreateRepository.php new file mode 100644 index 0000000000..571b7c1958 --- /dev/null +++ b/app/GraphQL/Mutations/CreateRepository.php @@ -0,0 +1,39 @@ +repository = $project?->repositories()->create([ + 'url' => $args['url'], + 'username' => $args['username'], + 'password' => $args['password'], + 'branch' => $args['branch'], + ]); + + return $this; + } +} diff --git a/app/GraphQL/Mutations/DeleteRepository.php b/app/GraphQL/Mutations/DeleteRepository.php new file mode 100644 index 0000000000..b653d535f9 --- /dev/null +++ b/app/GraphQL/Mutations/DeleteRepository.php @@ -0,0 +1,27 @@ +project); + + $repository?->delete(); + + return $this; + } +} diff --git a/app/Models/Repository.php b/app/Models/Repository.php index 483bd2f137..2cddf3d8f6 100644 --- a/app/Models/Repository.php +++ b/app/Models/Repository.php @@ -2,8 +2,11 @@ namespace App\Models; +use Database\Factories\RepositoryFactory; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $id @@ -17,6 +20,9 @@ */ class Repository extends Model { + /** @use HasFactory */ + use HasFactory; + protected $table = 'repositories'; public $timestamps = false; @@ -31,4 +37,12 @@ class Repository extends Model protected $casts = [ 'projectid' => 'integer', ]; + + /** + * @return BelongsTo + */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'projectid'); + } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 90ff5535bb..5a020dcbd1 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -156,6 +156,16 @@ public function updatePinnedTestMeasurementOrder(User $currentUser, Project $pro return $this->update($currentUser, $project); } + public function createRepository(User $currentUser, Project $project): bool + { + return $this->update($currentUser, $project); + } + + public function deleteRepository(User $currentUser, Project $project): bool + { + return $this->update($currentUser, $project); + } + private function isLdapControlledMembership(Project $project): bool { // If a LDAP filter has been specified and LDAP is enabled, CDash controls the entire members list. diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index dfb2c40778..df55201995 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -233,6 +233,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/BuildErrorTypeTest) add_feature_test_in_transaction(/Feature/GraphQL/CommentTypeTest) +add_feature_test_in_transaction(/Feature/GraphQL/RepositoryTypeTest) + add_feature_test_in_transaction(/Feature/RouteAccessTest) add_feature_test_in_transaction(/Feature/Monitor) @@ -290,6 +292,10 @@ add_feature_test_in_transaction(/Feature/GraphQL/Mutations/DeletePinnedTestMeasu add_feature_test_in_transaction(/Feature/GraphQL/Mutations/UpdatePinnedTestMeasurementOrderTest) +add_feature_test_in_transaction(/Feature/GraphQL/Mutations/CreateRepositoryTest) + +add_feature_test_in_transaction(/Feature/GraphQL/Mutations/DeleteRepositoryTest) + add_feature_test_in_transaction(/Feature/GlobalInvitationAcceptanceTest) add_feature_test_in_transaction(/Feature/GraphQL/GlobalInvitationTypeTest) diff --git a/database/factories/RepositoryFactory.php b/database/factories/RepositoryFactory.php new file mode 100644 index 0000000000..e0788f1a8b --- /dev/null +++ b/database/factories/RepositoryFactory.php @@ -0,0 +1,28 @@ + + */ +class RepositoryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'url' => fake()->url(), + 'username' => Str::uuid()->toString(), + 'password' => Str::uuid()->toString(), + 'branch' => Str::uuid()->toString(), + ]; + } +} diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 0f183f55db..125c76471d 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -131,6 +131,12 @@ type Mutation { previous maximum position. Only the relative order is guaranteed. """ updatePinnedTestMeasurementOrder(input: UpdatePinnedTestMeasurementOrderInput! @spread): UpdatePinnedTestMeasurementOrderMutationPayload! @field(resolver: "UpdatePinnedTestMeasurementOrder") + + "Add a repository to a project. Cannot retrieve password once set." + createRepository(input: CreateRepositoryInput! @spread): CreateRepositoryMutationPayload! @field(resolver: "CreateRepository") + + "Delete a repository by ID." + deleteRepository(input: DeleteRepositoryInput! @spread): DeleteRepositoryMutationPayload! @field(resolver: "DeleteRepository") } @@ -336,6 +342,9 @@ type Project { alongside Test details throughout the site. """ pinnedTestMeasurements: [PinnedTestMeasurement!]! @hasMany(type: CONNECTION) @orderBy(column: "position") + + "Repositories." + repositories: [Repository!]! @hasMany(type: CONNECTION) @orderBy(column: "id") } @@ -648,6 +657,39 @@ type UpdatePinnedTestMeasurementOrderMutationPayload implements MutationPayloadI } +input CreateRepositoryInput { + projectId: ID! + + url: Url! + + username: String! + + password: String! + + branch: String! +} + + +type CreateRepositoryMutationPayload implements MutationPayloadInterface { + "Optional error message." + message: String + + "The newly created repository." + repository: Repository +} + + +input DeleteRepositoryInput { + repositoryId: ID! +} + + +type DeleteRepositoryMutationPayload implements MutationPayloadInterface { + "Optional error message." + message: String +} + + "Configure." type Configure { "Unique primary key." @@ -1382,3 +1424,15 @@ type Comment { user: User! } + + +type Repository { + "Unique primary key." + id: ID! + + url: Url! + + username: String! + + branch: String! +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 761d5a7843..b765edc2a6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -282,12 +282,24 @@ parameters: count: 1 path: app/GraphQL/Mutations/CreatePinnedTestMeasurement.php + - + rawMessage: 'Parameter #2 $args (array{projectId: int, url: string, username: string, password: string, branch: string}) of method App\GraphQL\Mutations\CreateRepository::__invoke() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::__invoke()' + identifier: method.childParameterType + count: 1 + path: app/GraphQL/Mutations/CreateRepository.php + - rawMessage: 'Parameter #1 $args (array{id: int}) of method App\GraphQL\Mutations\DeletePinnedTestMeasurement::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' identifier: method.childParameterType count: 1 path: app/GraphQL/Mutations/DeletePinnedTestMeasurement.php + - + rawMessage: 'Parameter #2 $args (array{repositoryId: int}) of method App\GraphQL\Mutations\DeleteRepository::__invoke() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::__invoke()' + identifier: method.childParameterType + count: 1 + path: app/GraphQL/Mutations/DeleteRepository.php + - rawMessage: 'Parameter #1 $args (array{email: string, projectId: int, role: App\Enums\ProjectRole}) of method App\GraphQL\Mutations\InviteToProject::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' identifier: method.childParameterType diff --git a/tests/Feature/GraphQL/Mutations/CreateRepositoryTest.php b/tests/Feature/GraphQL/Mutations/CreateRepositoryTest.php new file mode 100644 index 0000000000..9115281d21 --- /dev/null +++ b/tests/Feature/GraphQL/Mutations/CreateRepositoryTest.php @@ -0,0 +1,234 @@ +makeAdminUser(); + + $this->actingAs($user)->graphQL(' + mutation createRepository($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + } + message + } + } + ', [ + 'input' => [ + 'projectId' => 123456789, + 'url' => fake()->url(), + 'username' => Str::uuid()->toString(), + 'password' => Str::uuid()->toString(), + 'branch' => Str::uuid()->toString(), + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseEmpty(Repository::class); + } + + public function testCannotCreateRepositoryAsAnonymousUser(): void + { + $project = $this->makePublicProject(); + + $this->graphQL(' + mutation createRepository($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + } + message + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'url' => fake()->url(), + 'username' => Str::uuid()->toString(), + 'password' => Str::uuid()->toString(), + 'branch' => Str::uuid()->toString(), + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseEmpty(Repository::class); + } + + public function testCannotCreateRepositoryAsNormalUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + + $this->actingAs($user)->graphQL(' + mutation createRepository($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + } + message + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'url' => fake()->url(), + 'username' => Str::uuid()->toString(), + 'password' => Str::uuid()->toString(), + 'branch' => Str::uuid()->toString(), + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseEmpty(Repository::class); + } + + public function testCannotCreateRepositoryAsNormalProjectUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + $project->users()->attach($user, ['role' => Project::PROJECT_USER]); + + $this->actingAs($user)->graphQL(' + mutation createRepository($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + } + message + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'url' => fake()->url(), + 'username' => Str::uuid()->toString(), + 'password' => Str::uuid()->toString(), + 'branch' => Str::uuid()->toString(), + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseEmpty(Repository::class); + } + + public function testCanCreateRepositoryAsProjectAdmin(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + $project->users()->attach($user, ['role' => Project::PROJECT_ADMIN]); + + $url = fake()->url(); + $username = Str::uuid()->toString(); + $password = Str::uuid()->toString(); + $branch = Str::uuid()->toString(); + + $response = $this->actingAs($user)->graphQL(' + mutation createRepository($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + url + username + branch + } + message + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'url' => $url, + 'username' => $username, + 'password' => $password, + 'branch' => $branch, + ], + ]); + + self::assertDatabaseCount(Repository::class, 1); + $repository = Repository::firstOrFail(); + self::assertSame($url, $repository->url); + self::assertSame($username, $repository->username); + self::assertSame($password, $repository->password); + self::assertSame($branch, $repository->branch); + + $response->assertExactJson([ + 'data' => [ + 'createRepository' => [ + 'repository' => [ + 'id' => (string) $repository->id, + 'url' => $url, + 'username' => $username, + 'branch' => $branch, + ], + 'message' => null, + ], + ], + ]); + } + + public function testCanCreateRepositoryAsGlobalAdmin(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $url = fake()->url(); + $username = Str::uuid()->toString(); + $password = Str::uuid()->toString(); + $branch = Str::uuid()->toString(); + + $response = $this->actingAs($user)->graphQL(' + mutation createRepository($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + url + username + branch + } + message + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'url' => $url, + 'username' => $username, + 'password' => $password, + 'branch' => $branch, + ], + ]); + + self::assertDatabaseCount(Repository::class, 1); + $repository = Repository::firstOrFail(); + self::assertSame($url, $repository->url); + self::assertSame($username, $repository->username); + self::assertSame($password, $repository->password); + self::assertSame($branch, $repository->branch); + + $response->assertExactJson([ + 'data' => [ + 'createRepository' => [ + 'repository' => [ + 'id' => (string) $repository->id, + 'url' => $url, + 'username' => $username, + 'branch' => $branch, + ], + 'message' => null, + ], + ], + ]); + } +} diff --git a/tests/Feature/GraphQL/Mutations/DeleteRepositoryTest.php b/tests/Feature/GraphQL/Mutations/DeleteRepositoryTest.php new file mode 100644 index 0000000000..b632cc3883 --- /dev/null +++ b/tests/Feature/GraphQL/Mutations/DeleteRepositoryTest.php @@ -0,0 +1,169 @@ +makeAdminUser(); + + $this->actingAs($user)->graphQL(' + mutation deleteRepository($input: DeleteRepositoryInput!) { + deleteRepository(input: $input) { + message + } + } + ', [ + 'input' => [ + 'repositoryId' => 123456789, + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseEmpty(Repository::class); + } + + public function testCannotDeleteRepositoryAsAnonymousUser(): void + { + $project = $this->makePublicProject(); + $repository = $project->repositories()->save(Repository::factory()->make()); + self::assertInstanceOf(Repository::class, $repository); + + self::assertDatabaseCount(Repository::class, 1); + + $this->graphQL(' + mutation deleteRepository($input: DeleteRepositoryInput!) { + deleteRepository(input: $input) { + message + } + } + ', [ + 'input' => [ + 'repositoryId' => $repository->id, + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseCount(Repository::class, 1); + } + + public function testCannotDeleteRepositoryAsNormalUser(): void + { + $user = $this->makeNormalUser(); + $project = $this->makePublicProject(); + $repository = $project->repositories()->save(Repository::factory()->make()); + self::assertInstanceOf(Repository::class, $repository); + + self::assertDatabaseCount(Repository::class, 1); + + $this->actingAs($user)->graphQL(' + mutation deleteRepository($input: DeleteRepositoryInput!) { + deleteRepository(input: $input) { + message + } + } + ', [ + 'input' => [ + 'repositoryId' => $repository->id, + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseCount(Repository::class, 1); + } + + public function testCannotDeleteRepositoryAsNormalProjectUser(): void + { + $user = $this->makeNormalUser(); + $project = $this->makePublicProject(); + $repository = $project->repositories()->save(Repository::factory()->make()); + $project->users()->attach($user, ['role' => Project::PROJECT_USER]); + self::assertInstanceOf(Repository::class, $repository); + + self::assertDatabaseCount(Repository::class, 1); + + $this->actingAs($user)->graphQL(' + mutation deleteRepository($input: DeleteRepositoryInput!) { + deleteRepository(input: $input) { + message + } + } + ', [ + 'input' => [ + 'repositoryId' => $repository->id, + ], + ])->assertGraphQLErrorMessage('This action is unauthorized.'); + + self::assertDatabaseCount(Repository::class, 1); + } + + public function testCanDeleteRepositoryAsProjectAdmin(): void + { + $user = $this->makeNormalUser(); + $project = $this->makePublicProject(); + $repository = $project->repositories()->save(Repository::factory()->make()); + $project->users()->attach($user, ['role' => Project::PROJECT_ADMIN]); + self::assertInstanceOf(Repository::class, $repository); + + self::assertDatabaseCount(Repository::class, 1); + + $this->actingAs($user)->graphQL(' + mutation deleteRepository($input: DeleteRepositoryInput!) { + deleteRepository(input: $input) { + message + } + } + ', [ + 'input' => [ + 'repositoryId' => $repository->id, + ], + ])->assertExactJson([ + 'data' => [ + 'deleteRepository' => [ + 'message' => null, + ], + ], + ]); + + self::assertDatabaseEmpty(Repository::class); + } + + public function testCanDeleteRepositoryAsGlobalAdmin(): void + { + $user = $this->makeAdminUser(); + $project = $this->makePublicProject(); + $repository = $project->repositories()->save(Repository::factory()->make()); + self::assertInstanceOf(Repository::class, $repository); + + self::assertDatabaseCount(Repository::class, 1); + + $this->actingAs($user)->graphQL(' + mutation deleteRepository($input: DeleteRepositoryInput!) { + deleteRepository(input: $input) { + message + } + } + ', [ + 'input' => [ + 'repositoryId' => $repository->id, + ], + ])->assertExactJson([ + 'data' => [ + 'deleteRepository' => [ + 'message' => null, + ], + ], + ]); + + self::assertDatabaseEmpty(Repository::class); + } +} diff --git a/tests/Feature/GraphQL/RepositoryTypeTest.php b/tests/Feature/GraphQL/RepositoryTypeTest.php new file mode 100644 index 0000000000..51ec901480 --- /dev/null +++ b/tests/Feature/GraphQL/RepositoryTypeTest.php @@ -0,0 +1,78 @@ +make(); + + return [ + ['url', $repository->url, 'url', $repository->url], + ['username', $repository->username, 'username', $repository->username], + ['branch', $repository->branch, 'branch', $repository->branch], + ]; + } + + /** + * A basic test to ensure that each of the non-relationship fields works + */ + #[DataProvider('fieldValues')] + public function testBasicFieldAccess(string $modelField, mixed $modelValue, string $graphqlField, mixed $graphqlValue): void + { + $project = $this->makePublicProject(); + $repository = $project->repositories()->save(Repository::factory()->make()); + self::assertInstanceOf(Repository::class, $repository); + $repository->setAttribute($modelField, $modelValue); + $repository->save(); + + $this->graphQL(" + query project(\$id: ID) { + project(id: \$id) { + repositories { + edges { + node { + id + $graphqlField + } + } + } + } + } + ", [ + 'id' => $project->id, + ])->assertExactJson([ + 'data' => [ + 'project' => [ + 'repositories' => [ + 'edges' => [ + [ + 'node' => [ + 'id' => (string) $repository->id, + $graphqlField => $graphqlValue, + ], + ], + ], + ], + ], + ], + ]); + } +}