diff --git a/app/Actions/Projects/DeleteProject.php b/app/Actions/Projects/DeleteProject.php index 02a111d..da2499f 100644 --- a/app/Actions/Projects/DeleteProject.php +++ b/app/Actions/Projects/DeleteProject.php @@ -3,6 +3,7 @@ namespace App\Actions\Projects; use App\Models\Project; +use Illuminate\Support\Facades\DB; class DeleteProject { @@ -10,7 +11,12 @@ class DeleteProject { $projectName = $project->name; - $project->delete(); + DB::transaction(function () use ($project): void { + $baseTemplate = $project->baseTemplate()->first(); + + $project->delete(); + $baseTemplate?->delete(); + }); return $projectName; } diff --git a/app/Http/Requests/DeleteProjectRequest.php b/app/Http/Requests/DeleteProjectRequest.php index bec6563..3654ea7 100644 --- a/app/Http/Requests/DeleteProjectRequest.php +++ b/app/Http/Requests/DeleteProjectRequest.php @@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest { public function authorize(): bool { - return $this->user()?->can('access-admin') ?? false; + return $this->user()?->can('admin-project', $this->route('project')) ?? false; } /** diff --git a/routes/web/projects.php b/routes/web/projects.php index 60a5553..880e3d3 100644 --- a/routes/web/projects.php +++ b/routes/web/projects.php @@ -27,7 +27,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void { }); Route::delete('/', [ProjectController::class, 'destroy']) - ->middleware('can:access-admin') + ->middleware('can:admin-project,project') ->name('destroy'); Route::middleware('can:admin-project,project')->group(function (): void { diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index 1fc8846..fbccb00 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -291,7 +291,14 @@ class ProjectApiTest extends TestCase ->assertOk() ->assertJsonPath('data.key', 'CORE'); - $this->syncGlobalPermissions($admin, ['admin']); + $this->withApplicationKey($user) + ->deleteJson(route('api.projects.destroy', $project), [ + 'confirmation_key' => 'CORE', + ]) + ->assertForbidden(); + + $this->syncProjectPermissions($project, $admin, ['admin']); + $this->assertTrue($admin->can('admin-project', $project)); $this->withApplicationKey($admin) ->deleteJson(route('api.projects.destroy', $project), [ diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index 54f9c32..8e4ea02 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -817,6 +817,75 @@ class ProjectControllerTest extends TestCase ]); } + public function test_project_admins_can_delete_their_own_projects_and_reuse_the_key(): void + { + $user = User::factory()->create(); + $this->syncGlobalPermissions($user, ['create']); + + $this->actingAs($user) + ->post(route('projects.store'), [ + 'creation_mode' => 'scratch', + 'name' => 'Core Platform', + 'key' => 'CORE', + ]) + ->assertRedirect(); + + $project = Project::query()->where('key', 'CORE')->firstOrFail(); + $baseTemplateId = $project->project_template_id; + $this->assertTrue($user->can('admin-project', $project)); + + $this->actingAs($user) + ->delete(route('projects.destroy', $project), [ + 'confirmation_key' => 'CORE', + ]) + ->assertRedirect(route('projects.index')); + + $this->assertDatabaseMissing('projects', ['id' => $project->id]); + $this->assertDatabaseMissing('project_templates', ['id' => $baseTemplateId]); + + $this->actingAs($user) + ->post(route('projects.store'), [ + 'creation_mode' => 'scratch', + 'name' => 'New Core Platform', + 'key' => 'CORE', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('projects', [ + 'key' => 'CORE', + 'name' => 'New Core Platform', + ]); + } + + public function test_deleting_a_project_preserves_its_published_template_and_linked_projects(): void + { + $admin = User::factory()->admin()->create(); + $project = Project::factory()->create(['key' => 'CORE']); + $otherProject = Project::factory()->create(['key' => 'OTHER']); + $template = ProjectTemplate::factory()->create([ + 'source_project_id' => $project->id, + 'is_published' => true, + ]); + + $project->update(['project_template_id' => $template->id]); + $otherProject->update(['project_template_id' => $template->id]); + + $this->actingAs($admin) + ->delete(route('projects.destroy', $project), [ + 'confirmation_key' => 'CORE', + ]) + ->assertRedirect(route('projects.index')); + + $this->assertDatabaseHas('project_templates', [ + 'id' => $template->id, + 'source_project_id' => null, + ]); + $this->assertDatabaseHas('projects', [ + 'id' => $otherProject->id, + 'project_template_id' => $template->id, + ]); + } + /** * Ensure project deletion requires the exact project key confirmation. */ @@ -841,16 +910,18 @@ class ProjectControllerTest extends TestCase } /** - * Ensure non-admin users cannot delete projects. + * Ensure users without project administration cannot delete projects. */ - public function test_non_admin_users_cannot_delete_projects(): void + public function test_project_readers_cannot_delete_projects(): void { $user = User::factory()->create(); + $otherProject = Project::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); - $this->syncProjectPermissions($project, $user, ['admin']); + $this->syncProjectPermissions($otherProject, $user, ['admin']); + $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->delete(route('projects.destroy', $project), [