Files
KanbanGit/tests/Feature/Projects/ProjectControllerTest.php
T
Twyco 78e321f665
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m40s
CI/CD / Publish immutable image (pull_request) Skipped
fix: allow project admins to delete projects (KAN-64)
2026-09-30 11:20:09 +02:00

1013 lines
36 KiB
PHP

<?php
namespace Tests\Feature\Projects;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\RepositoryConnection;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure guests cannot access authenticated project pages.
*/
public function test_guests_are_redirected_from_project_pages(): void
{
$this->get(route('projects.index'))
->assertRedirect(route('login'));
$this->get(route('projects.create'))
->assertRedirect(route('login'));
}
/**
* Ensure authenticated users can view the projects index.
*/
public function test_authenticated_users_can_view_the_projects_index(): void
{
$user = User::factory()->admin()->create();
$firstProject = Project::factory()->create([
'name' => 'Core Platform',
'key' => 'CORE',
]);
$secondProject = Project::factory()->create([
'name' => 'Authentication',
'key' => 'AUTH',
]);
$this->actingAs($user)
->get(route('projects.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Index')
->has('projects', 2)
->where('projects.0.id', $secondProject->id)
->where('projects.1.id', $firstProject->id),
);
}
/**
* Ensure non-admin users only see projects they can access directly or through teams.
*/
public function test_users_only_see_accessible_projects_on_the_index(): void
{
$user = User::factory()->create();
$team = Team::factory()->create([
'name' => 'Platform Team',
]);
$team->users()->attach($user);
$directProject = Project::factory()->create([
'name' => 'Direct Access',
'key' => 'DIR',
]);
$teamProject = Project::factory()->create([
'name' => 'Team Access',
'key' => 'TEAM',
]);
Project::factory()->create([
'name' => 'Hidden Project',
'key' => 'HIDE',
]);
$this->syncProjectPermissions($directProject, $user, ['view']);
$this->syncProjectPermissions($teamProject, $team, ['view']);
$this->actingAs($user)
->get(route('projects.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Index')
->has('projects', 2)
->where('projects.0.id', $teamProject->id)
->where('projects.1.id', $directProject->id),
);
}
/**
* Ensure users with project create permission can view the project creation screen.
*/
public function test_authenticated_users_can_view_the_project_creation_screen(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$visibleProject = Project::factory()->create([
'name' => 'Visible Source',
'key' => 'SRC1',
]);
Project::factory()->create([
'name' => 'Hidden Source',
'key' => 'SRC2',
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Reusable Setup',
'slug' => 'reusable-setup',
'source_project_id' => $visibleProject->id,
]);
ProjectTemplate::factory()->create([
'name' => 'Hidden Base',
'slug' => 'hidden-base',
'project_id' => $visibleProject->id,
'source_project_id' => $visibleProject->id,
'is_published' => false,
]);
$this->syncProjectPermissions($visibleProject, $user, ['view']);
$this->actingAs($user)
->get(route('projects.create', ['template' => $template->id]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Create')
->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas()))
->has('sourceProjects', 1)
->has('sourceTemplates', 1)
->where('sourceProjects.0.id', $visibleProject->id)
->where('sourceTemplates.0.id', $template->id)
->where('selectedSourceTemplateId', $template->id)
->where('copyAreaOptions.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
);
}
/**
* Ensure users without project create permission cannot view the project creation screen.
*/
public function test_users_without_project_create_permission_cannot_view_the_project_creation_screen(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('projects.create'))
->assertForbidden();
}
/**
* Ensure users with project create permission can create projects with uppercase keys.
*/
public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$response = $this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'Kanban Foundation',
'key' => 'core',
]);
$project = Project::query()->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$this->assertDatabaseHas('projects', [
'name' => 'Kanban Foundation',
'key' => 'CORE',
]);
$project->refresh();
$this->assertNotNull($project->project_template_id);
$this->assertDatabaseHas('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id,
'source_project_id' => $project->id,
'is_published' => false,
'slug' => 'core-base-template',
]);
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $user->id,
'assignee_type' => $user->getMorphClass(),
'permission' => 'project:admin',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Task',
'is_parent' => false,
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Release',
'is_parent' => true,
]);
}
/**
* Ensure project keys remain unique after normalization.
*/
public function test_project_keys_must_be_unique_case_insensitively(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
Project::factory()->create([
'key' => 'AUTH',
]);
$this->actingAs($user)
->from(route('projects.create'))
->post(route('projects.store'), [
'name' => 'Another Auth Project',
'key' => 'auth',
])
->assertSessionHasErrors('key')
->assertRedirect(route('projects.create'));
}
public function test_authenticated_users_can_create_projects_from_saved_templates(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$sourceProject = Project::factory()->create([
'key' => 'SRC1',
'name' => 'Source Project',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'source_project_id' => $sourceProject->id,
'name' => 'Kanban Template',
'slug' => 'kanban-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
],
]);
$response = $this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'template',
'name' => 'Template Based Project',
'key' => 'TMP1',
'source_template_id' => $template->id,
]);
$project = Project::query()->where('key', 'TMP1')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$this->assertDatabaseMissing('project_templates', [
'project_id' => $project->id,
'is_published' => false,
]);
$this->assertSame(false, $project->show_author);
$this->assertSame(false, $project->show_story_points);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $template->id,
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
]);
$this->assertDatabaseMissing('tickets', [
'project_id' => $project->id,
]);
}
public function test_authenticated_users_can_duplicate_projects_with_selected_settings_only(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$sourceProject = Project::factory()->create([
'key' => 'SRC1',
'name' => 'Source Project',
'show_author' => false,
'show_story_points' => false,
]);
$this->syncProjectPermissions($sourceProject, $user, ['view']);
$sourceProject->ticketTypes()->delete();
$sourceProject->ticketPriorities()->delete();
$sourceProject->ticketStatuses()->delete();
$sourceProject->boardColumns()->delete();
$todoColumn = BoardColumn::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Todo',
'position' => 1,
]);
$doingColumn = BoardColumn::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Doing',
'position' => 2,
]);
$backlogStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => null,
'name' => 'Planned',
'position' => 1,
]);
$todoStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => $todoColumn->id,
'name' => 'Todo',
'position' => 1,
]);
$doingStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => $doingColumn->id,
'name' => 'Doing',
'position' => 1,
]);
$todoStatus->allowedTargetStatuses()->sync([$doingStatus->id]);
$backlogStatus->allowedTargetStatuses()->sync([$todoStatus->id]);
TicketType::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Bug',
'position' => 1,
]);
TicketType::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Epic',
'position' => 2,
'is_parent' => true,
]);
TicketPriority::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Urgent',
'position' => 1,
]);
TicketFieldDefinition::factory()->create([
'project_id' => $sourceProject->id,
'field_type' => 'number',
'label' => 'Story estimate',
'position' => 1,
]);
TicketLinkType::factory()->create([
'project_id' => $sourceProject->id,
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 1,
]);
RepositoryConnection::factory()->create([
'project_id' => $sourceProject->id,
'repository_full_name' => 'OpenSource/source-project',
]);
NotificationAction::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Notify board changes',
]);
Ticket::factory()->create([
'project_id' => $sourceProject->id,
'sequence_number' => 42,
'title' => 'Existing ticket',
]);
$response = $this->actingAs($user)
->post(route('projects.store'), [
'name' => 'Duplicated Project',
'key' => 'dupe',
'creation_mode' => 'duplicate',
'source_project_id' => $sourceProject->id,
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
],
]);
$response->assertRedirect()->assertSessionHasNoErrors();
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$this->assertSame('Duplicated Project', $project->name);
$this->assertFalse($project->show_author);
$this->assertFalse($project->show_story_points);
$this->assertSame(0, $project->tickets()->count());
$this->assertSame(['Todo', 'Doing'], $project->boardColumns()->pluck('name')->all());
$this->assertSame(['Planned', 'Todo', 'Doing'], $project->ticketStatuses()->pluck('name')->all());
$this->assertSame(['Bug', 'Epic'], $project->ticketTypes()->pluck('name')->all());
$this->assertSame(['Story estimate'], $project->ticketFieldDefinitions()->pluck('label')->all());
$this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all());
$this->assertCount(0, $project->ticketLinkTypes);
$this->assertCount(0, $project->repositoryConnections);
$this->assertCount(0, $project->notificationActions);
$duplicatedTodoStatus = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
$this->assertSame(['Doing'], $duplicatedTodoStatus->allowedTargetStatuses()->pluck('name')->all());
}
public function test_project_duplication_requires_accessible_source_projects(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$sourceProject = Project::factory()->create();
$this->actingAs($user)
->from(route('projects.create'))
->post(route('projects.store'), [
'name' => 'Denied Duplicate',
'key' => 'deny',
'creation_mode' => 'duplicate',
'source_project_id' => $sourceProject->id,
])
->assertSessionHasErrors('source_project_id')
->assertRedirect(route('projects.create'));
}
/**
* Ensure project keys cannot exceed five characters.
*/
public function test_project_keys_may_not_exceed_five_characters(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->from(route('projects.create'))
->post(route('projects.store'), [
'name' => 'Too Long',
'key' => 'KANBAN',
])
->assertSessionHasErrors('key')
->assertRedirect(route('projects.create'));
}
/**
* Ensure authenticated users can view the project hub page.
*/
public function test_authenticated_users_can_view_a_project_hub_page(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'name' => 'Core Platform',
'key' => 'CORE',
]);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('canManageAccess', false)
->where('project.id', $project->id)
->where('project.key', 'CORE'),
);
}
/**
* Ensure team-inherited permissions allow users to open a project.
*/
public function test_team_permissions_allow_users_to_view_a_project(): void
{
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->create([
'key' => 'TEAM',
]);
$team->users()->attach($user);
$this->syncProjectPermissions($project, $team, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('project.id', $project->id),
);
}
/**
* Ensure users without direct or inherited access are forbidden.
*/
public function test_users_without_permission_cannot_view_a_project(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->actingAs($user)
->get(route('projects.show', $project))
->assertForbidden();
}
/**
* Ensure project admin access allows opening the project access management page.
*/
public function test_users_with_project_admin_permission_can_view_project_access_management(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$team = Team::factory()->create([
'name' => 'Platform Team',
]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.access', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Access')
->where('project.id', $project->id)
->has('users', 1)
->has('teams', 1)
->where('permissionOptions.0.value', 'project:read'),
);
}
/**
* Ensure admins can open the general project settings page.
*/
public function test_admins_can_view_general_project_settings(): void
{
$admin = User::factory()->admin()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$this->actingAs($admin)
->get(route('projects.settings.general', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/General')
->where('project.id', $project->id)
->where('project.key', 'CORE'),
);
}
/**
* Ensure project admins can open general project settings.
*/
public function test_project_admins_can_view_general_project_settings(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.settings.general', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/General')
->where('project.id', $project->id),
);
}
public function test_project_admins_can_switch_projects_to_another_template(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
'show_author' => false,
'show_story_points' => false,
]);
$currentTemplate = ProjectTemplate::factory()->create([
'name' => 'Current Template',
'slug' => 'current-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
],
],
]);
$newTemplate = ProjectTemplate::factory()->create([
'name' => 'Operations Template',
'slug' => 'operations-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Incident', 'position' => 1, 'is_parent' => false],
['name' => 'Maintenance', 'position' => 2, 'is_parent' => false],
],
],
]);
$project->update(['project_template_id' => $currentTemplate->id]);
$project->ticketTypes()->delete();
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.switch-template', $project), [
'confirmation_key' => 'CORE',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHas('status', 'Core Platform now uses the Operations Template template.');
$project->refresh();
$this->assertSame($newTemplate->id, $project->project_template_id);
$this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points);
$this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all());
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $newTemplate->id,
'name' => 'Incident',
]);
}
public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Workflow Template',
'slug' => 'workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
['name' => 'Feature', 'position' => 2, 'is_parent' => false],
],
],
]);
$project->update(['project_template_id' => $template->id]);
$project->ticketTypes()->delete();
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.reset-template', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.');
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points);
$this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all());
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $template->id,
'name' => 'Bug',
]);
}
public function test_project_template_switch_requires_exact_project_key_confirmation(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$project->update([
'project_template_id' => ProjectTemplate::factory()->create()->id,
]);
$newTemplate = ProjectTemplate::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.switch-template', $project), [
'confirmation_key' => 'core',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHasErrors('confirmation_key');
$this->assertNotSame($newTemplate->id, $project->fresh()->project_template_id);
}
/**
* Ensure admins can delete projects and their dependent project-scoped data.
*/
public function test_admins_can_delete_projects_from_general_settings(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'In Progress',
'position' => 1,
]);
$ticketStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'In Progress',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $ticketStatus->id,
'ticket_type_id' => $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail()
->id,
'title' => 'Delete me',
]);
$this->syncProjectPermissions($project, $member, ['view']);
$this->actingAs($admin)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'))
->assertSessionHas('status', 'Project Core Platform was deleted.');
$this->assertDatabaseMissing('projects', [
'id' => $project->id,
]);
$this->assertDatabaseMissing('board_columns', [
'id' => $boardColumn->id,
]);
$this->assertDatabaseMissing('tickets', [
'id' => $ticket->id,
]);
$this->assertDatabaseMissing('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
}
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.
*/
public function test_admins_must_confirm_the_exact_project_key_to_delete_a_project(): void
{
$admin = User::factory()->admin()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$this->actingAs($admin)
->from(route('projects.settings.general', $project))
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'core',
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHasErrors('confirmation_key');
$this->assertDatabaseHas('projects', [
'id' => $project->id,
]);
}
/**
* Ensure users without project administration cannot delete projects.
*/
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($otherProject, $user, ['admin']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertForbidden();
$this->assertDatabaseHas('projects', [
'id' => $project->id,
]);
}
/**
* Ensure admins can update direct project permissions for a user.
*/
public function test_admins_can_update_direct_project_permissions(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create([
'name' => 'Regular User',
]);
$project = Project::factory()->create([
'key' => 'AUTH',
]);
$this->actingAs($admin)
->patch(route('projects.permissions.users.update', [$project, $member]), [
'permissions' => ['project:read', 'project:write'],
])
->assertRedirect(route('projects.access', $project))
->assertSessionHas('status', 'Regular User permissions were updated.');
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:write',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
}
/**
* Ensure users with project admin access can update team permissions.
*/
public function test_users_with_project_admin_permission_can_update_team_permissions(): void
{
$manager = User::factory()->create();
$team = Team::factory()->create([
'name' => 'Delivery Team',
]);
$project = Project::factory()->create([
'key' => 'OPS',
]);
$this->syncProjectPermissions($project, $manager, ['admin']);
$this->actingAs($manager)
->patch(route('projects.permissions.teams.update', [$project, $team]), [
'permissions' => ['project:read', 'project:write'],
])
->assertRedirect(route('projects.access', $project))
->assertSessionHas('status', 'Delivery Team permissions were updated.');
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $team->id,
'assignee_type' => $team->getMorphClass(),
'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $team->id,
'assignee_type' => $team->getMorphClass(),
'permission' => 'project:write',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
}
}