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']); $this->actingAs($user) ->get(route('projects.create')) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Create'), ); } /** * 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'), [ 'name' => 'Kanban Foundation', 'key' => 'core', ]); $project = Project::query()->firstOrFail(); $response->assertRedirect(route('projects.show', $project)); $this->assertDatabaseHas('projects', [ 'name' => 'Kanban Foundation', 'key' => 'CORE', ]); $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')); } /** * 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), ); } /** * 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(), ]); } /** * 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 non-admin users cannot delete projects. */ public function test_non_admin_users_cannot_delete_projects(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['admin']); $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(), ]); } }