Skip to content

Commit 48486ba

Browse files
Use custom resolver for createProject mutation (#3372)
This PR re-implements the `createProject` mutation with a custom resolver which is backed up by the `ProjectService`. This ensures that there's a single source of truth about what it means to create a project and associated records. I also moved the relevant tests to a dedicated file, to bring this in line with the rest of the mutations. I plan to change the return signature in CDash 5.0 to align this mutation with the rest of the mutations.
1 parent d5d514a commit 48486ba

5 files changed

Lines changed: 375 additions & 299 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\GraphQL\Mutations;
6+
7+
use App\Models\Project;
8+
use App\Services\ProjectService;
9+
use Illuminate\Support\Facades\Gate;
10+
11+
class CreateProject
12+
{
13+
/**
14+
* @param array<string,mixed> $args
15+
*/
16+
public function __invoke(null $_, array $args): Project
17+
{
18+
Gate::authorize('create', Project::class);
19+
return ProjectService::create($args);
20+
}
21+
}

app/cdash/tests/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ add_feature_test(/Feature/Mail/AuthTokenExpiringTest)
320320

321321
add_feature_test(/Feature/Mail/AuthTokenExpiredTest)
322322

323+
add_feature_test(/Feature/GraphQL/Mutations/CreateProjectTest)
324+
323325
add_feature_test(/Feature/GraphQL/Mutations/UpdateSiteDescriptionTest)
324326

325327
add_feature_test(/Feature/GraphQL/Mutations/InviteToProjectTest)

graphql/schema.graphql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ type Query {
6464

6565

6666
type Mutation {
67+
# TODO: Change the return type to be a payload type instead of a project
6768
"Create a new project."
68-
createProject(input: CreateProjectInput! @spread): Project @create @canModel(ability: "create")
69+
createProject(input: CreateProjectInput! @spread): Project @field(resolver: "CreateProject")
6970

7071
"Subscribe the current user to the specified site."
7172
claimSite(input: ClaimSiteInput! @spread): ClaimSiteMutationPayload! @field(resolver: "ClaimSite")
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
<?php
2+
3+
namespace Tests\Feature\GraphQL\Mutations;
4+
5+
use App\Models\Project;
6+
use App\Models\User;
7+
use Illuminate\Support\Facades\Config;
8+
use Illuminate\Support\Str;
9+
use PHPUnit\Framework\Attributes\DataProvider;
10+
use Tests\TestCase;
11+
use Tests\Traits\CreatesUsers;
12+
13+
class CreateProjectTest extends TestCase
14+
{
15+
use CreatesUsers;
16+
17+
/**
18+
* @var array<Project>
19+
*/
20+
private array $projects = [];
21+
22+
/**
23+
* @var array<User>
24+
*/
25+
private array $users = [];
26+
27+
protected function tearDown(): void
28+
{
29+
foreach ($this->projects as $project) {
30+
$project->delete();
31+
}
32+
$this->projects = [];
33+
34+
foreach ($this->users as $user) {
35+
$user->delete();
36+
}
37+
$this->users = [];
38+
39+
parent::tearDown();
40+
}
41+
42+
public function testCreateProjectNoUser(): void
43+
{
44+
$name = 'test-project' . Str::uuid();
45+
$this->graphQL('
46+
mutation CreateProject($input: CreateProjectInput!) {
47+
createProject(input: $input) {
48+
id
49+
name
50+
}
51+
}
52+
', [
53+
'input' => [
54+
'name' => $name,
55+
'description' => 'test',
56+
'homeUrl' => 'https://cdash.org',
57+
'visibility' => 'PUBLIC',
58+
'authenticateSubmissions' => false,
59+
],
60+
])->assertGraphQLErrorMessage('This action is unauthorized.');
61+
62+
// A final check to ensure this project wasn't created anyway
63+
$this->assertDatabaseMissing(Project::class, [
64+
'name' => $name,
65+
]);
66+
}
67+
68+
public function testCreateProjectUnauthorizedUser(): void
69+
{
70+
$this->users['normal'] = $this->makeNormalUser();
71+
72+
$name = 'test-project' . Str::uuid();
73+
$this->actingAs($this->users['normal'])->graphQL('
74+
mutation CreateProject($input: CreateProjectInput!) {
75+
createProject(input: $input) {
76+
id
77+
name
78+
}
79+
}
80+
', [
81+
'input' => [
82+
'name' => $name,
83+
'description' => 'test',
84+
'homeUrl' => 'https://cdash.org',
85+
'visibility' => 'PUBLIC',
86+
'authenticateSubmissions' => false,
87+
],
88+
])->assertGraphQLErrorMessage('This action is unauthorized.');
89+
90+
// A final check to ensure this project wasn't created anyway
91+
$this->assertDatabaseMissing(Project::class, [
92+
'name' => $name,
93+
]);
94+
}
95+
96+
public function testCreateProjectUserCreateProjectNoUser(): void
97+
{
98+
Config::set('cdash.user_create_projects', true);
99+
100+
$name = 'test-project' . Str::uuid();
101+
$this->graphQL('
102+
mutation CreateProject($input: CreateProjectInput!) {
103+
createProject(input: $input) {
104+
id
105+
name
106+
}
107+
}
108+
', [
109+
'input' => [
110+
'name' => $name,
111+
'description' => 'test',
112+
'homeUrl' => 'https://cdash.org',
113+
'visibility' => 'PUBLIC',
114+
'authenticateSubmissions' => false,
115+
],
116+
])->assertGraphQLErrorMessage('This action is unauthorized.');
117+
118+
// A final check to ensure this project wasn't created anyway
119+
$this->assertDatabaseMissing(Project::class, [
120+
'name' => $name,
121+
]);
122+
}
123+
124+
public function testCreateProjectUserCreateProject(): void
125+
{
126+
Config::set('cdash.user_create_projects', true);
127+
128+
$this->users['normal'] = $this->makeNormalUser();
129+
130+
$name = 'test-project' . Str::uuid();
131+
$response = $this->actingAs($this->users['normal'])->graphQL('
132+
mutation CreateProject($input: CreateProjectInput!) {
133+
createProject(input: $input) {
134+
id
135+
name
136+
}
137+
}
138+
', [
139+
'input' => [
140+
'name' => $name,
141+
'description' => 'test',
142+
'homeUrl' => 'https://cdash.org',
143+
'visibility' => 'PUBLIC',
144+
'authenticateSubmissions' => false,
145+
],
146+
]);
147+
148+
$project = Project::where('name', $name)->firstOrFail();
149+
$this->projects[] = $project;
150+
151+
$response->assertExactJson([
152+
'data' => [
153+
'createProject' => [
154+
'id' => (string) $project->id,
155+
'name' => $name,
156+
],
157+
],
158+
]);
159+
160+
$project->delete();
161+
}
162+
163+
public function testCreateProjectAdmin(): void
164+
{
165+
$this->users['admin'] = $this->makeAdminUser();
166+
167+
$name = 'test-project' . Str::uuid();
168+
$response = $this->actingAs($this->users['admin'])->graphQL('
169+
mutation CreateProject($input: CreateProjectInput!) {
170+
createProject(input: $input) {
171+
id
172+
name
173+
}
174+
}
175+
', [
176+
'input' => [
177+
'name' => $name,
178+
'description' => 'test',
179+
'homeUrl' => 'https://cdash.org',
180+
'visibility' => 'PUBLIC',
181+
'authenticateSubmissions' => false,
182+
],
183+
]);
184+
185+
$project = Project::where('name', $name)->firstOrFail();
186+
$this->projects[] = $project;
187+
188+
$response->assertExactJson([
189+
'data' => [
190+
'createProject' => [
191+
'id' => (string) $project->id,
192+
'name' => $name,
193+
],
194+
],
195+
]);
196+
197+
$project->delete();
198+
}
199+
200+
/**
201+
* @return array{
202+
* array{
203+
* string,
204+
* string,
205+
* string,
206+
* bool
207+
* }
208+
* }
209+
*/
210+
public static function createProjectVisibilityRules(): array
211+
{
212+
return [
213+
['normal', 'PUBLIC', 'PUBLIC', true],
214+
['normal', 'PROTECTED', 'PUBLIC', true],
215+
['normal', 'PRIVATE', 'PUBLIC', true],
216+
['normal', 'PUBLIC', 'PROTECTED', false],
217+
['normal', 'PROTECTED', 'PROTECTED', true],
218+
['normal', 'PRIVATE', 'PROTECTED', true],
219+
['normal', 'PUBLIC', 'PRIVATE', false],
220+
['normal', 'PROTECTED', 'PRIVATE', false],
221+
['normal', 'PRIVATE', 'PRIVATE', true],
222+
['admin', 'PUBLIC', 'PUBLIC', true],
223+
['admin', 'PROTECTED', 'PUBLIC', true],
224+
['admin', 'PRIVATE', 'PUBLIC', true],
225+
['admin', 'PUBLIC', 'PROTECTED', true],
226+
['admin', 'PROTECTED', 'PROTECTED', true],
227+
['admin', 'PRIVATE', 'PROTECTED', true],
228+
['admin', 'PUBLIC', 'PRIVATE', true],
229+
['admin', 'PROTECTED', 'PRIVATE', true],
230+
['admin', 'PRIVATE', 'PRIVATE', true],
231+
];
232+
}
233+
234+
#[DataProvider('createProjectVisibilityRules')]
235+
public function testCreateProjectMaxVisibility(string $user, string $visibility, string $max_visibility, bool $can_create): void
236+
{
237+
Config::set('cdash.user_create_projects', true);
238+
Config::set('cdash.max_project_visibility', $max_visibility);
239+
240+
$this->users['normal'] = $this->makeNormalUser();
241+
$this->users['admin'] = $this->makeAdminUser();
242+
243+
$name = 'test-project' . Str::uuid();
244+
$response = $this->actingAs($this->users[$user])->graphQL('
245+
mutation CreateProject($input: CreateProjectInput!) {
246+
createProject(input: $input) {
247+
visibility
248+
}
249+
}
250+
', [
251+
'input' => [
252+
'name' => $name,
253+
'description' => 'test',
254+
'homeUrl' => 'https://cdash.org',
255+
'visibility' => $visibility,
256+
'authenticateSubmissions' => false,
257+
],
258+
]);
259+
260+
if ($can_create) {
261+
$this->projects[] = Project::where('name', $name)->firstOrFail();
262+
$response->assertExactJson([
263+
'data' => [
264+
'createProject' => [
265+
'visibility' => $visibility,
266+
],
267+
],
268+
]);
269+
} else {
270+
// A final check to ensure this project wasn't created anyway
271+
$this->assertDatabaseMissing(Project::class, [
272+
'name' => $name,
273+
]);
274+
$response->assertGraphQLErrorMessage('Validation failed for the field [createProject].');
275+
}
276+
}
277+
278+
/**
279+
* @return array{
280+
* array{
281+
* string,
282+
* bool,
283+
* bool,
284+
* bool
285+
* }
286+
* }
287+
*/
288+
public static function authenticatedSubmissionRules(): array
289+
{
290+
return [
291+
['normal', false, false, true],
292+
['normal', true, false, true],
293+
['normal', false, true, false],
294+
['normal', true, true, true],
295+
// Instance admins can set any value
296+
['admin', false, false, true],
297+
['admin', true, false, true],
298+
['admin', false, true, true],
299+
['admin', true, true, true],
300+
];
301+
}
302+
303+
#[DataProvider('authenticatedSubmissionRules')]
304+
public function testRequireAuthenticatedSubmissions(
305+
string $user,
306+
bool $use_authenticated_submits,
307+
bool $require_authenticated_submissions,
308+
bool $result,
309+
): void {
310+
Config::set('cdash.user_create_projects', true);
311+
Config::set('cdash.require_authenticated_submissions', $require_authenticated_submissions);
312+
313+
$this->users['normal'] = $this->makeNormalUser();
314+
$this->users['admin'] = $this->makeAdminUser();
315+
316+
$name = 'test-project' . Str::uuid();
317+
$response = $this->actingAs($this->users[$user])->graphQL('
318+
mutation CreateProject($input: CreateProjectInput!) {
319+
createProject(input: $input) {
320+
authenticateSubmissions
321+
}
322+
}
323+
', [
324+
'input' => [
325+
'name' => $name,
326+
'description' => 'test',
327+
'homeUrl' => 'https://cdash.org',
328+
'visibility' => 'PUBLIC',
329+
'authenticateSubmissions' => $use_authenticated_submits,
330+
],
331+
]);
332+
333+
if ($result) {
334+
$this->projects[] = Project::where('name', $name)->firstOrFail();
335+
$response->assertExactJson([
336+
'data' => [
337+
'createProject' => [
338+
'authenticateSubmissions' => $use_authenticated_submits,
339+
],
340+
],
341+
]);
342+
} else {
343+
// A final check to ensure this project wasn't created anyway
344+
$this->assertDatabaseMissing(Project::class, [
345+
'name' => $name,
346+
]);
347+
$response->assertGraphQLErrorMessage('Validation failed for the field [createProject].');
348+
}
349+
}
350+
}

0 commit comments

Comments
 (0)