feat(KAN-48): rebuild project access and permission assignment
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_access_summary_matches_existing_permission_rules_and_excludes_other_scopes(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$writer = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$outsider = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$otherProject = Project::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$team->users()->attach([$writer->id, $member->id]);
|
||||
$otherTeam->users()->attach($writer);
|
||||
$service = app(PermissionService::class);
|
||||
$service->sync($writer, $project, [Permission::ProjectRead]);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$service->sync($otherTeam, $project, [Permission::ProjectRead]);
|
||||
$service->sync($outsider, $otherProject, [Permission::ProjectAdmin]);
|
||||
$service->sync($outsider, null, [Permission::ProjectCreate]);
|
||||
$service->sync($outsider, $team, [Permission::TeamAdmin]);
|
||||
|
||||
$map = $service->projectAccessMap(User::all(), $project);
|
||||
foreach (User::all() as $user) {
|
||||
$expected = array_values(array_filter(Permission::projectAssignable(), fn (Permission $permission): bool => $service->allows($user, $permission, $project)));
|
||||
$this->assertSame(Permission::values($expected), $map['effectivePermissions'][$user->id]);
|
||||
}
|
||||
$this->assertSame([], $map['effectivePermissions'][$outsider->id]);
|
||||
$this->assertSame(['project:read', 'project:write'], $map['effectivePermissions'][$writer->id]);
|
||||
$this->assertSame([$admin->id], $map['globalAdminUserIds']);
|
||||
$this->actingAs($admin)->get(route('projects.access', $project))->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page->component('projects/Access')
|
||||
->where("effectivePermissions.{$writer->id}", ['project:read', 'project:write'])
|
||||
->where("directPermissions.{$writer->id}", ['project:read'])
|
||||
->where("teamPermissions.{$team->id}", ['project:write'])
|
||||
->where('globalAdminUserIds', [$admin->id]));
|
||||
}
|
||||
|
||||
public function test_bulk_access_summary_uses_a_bounded_number_of_queries(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
User::factory()->create();
|
||||
$service = app(PermissionService::class);
|
||||
$countQueries = function () use ($service, $project): int {
|
||||
$users = User::all();
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$service->projectAccessMap($users, $project);
|
||||
$count = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
return $count;
|
||||
};
|
||||
$initialCount = $countQueries();
|
||||
$users = User::factory()->count(10)->create();
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($users);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$this->assertSame($initialCount, $countQueries());
|
||||
$this->assertLessThanOrEqual(4, $initialCount);
|
||||
}
|
||||
|
||||
public function test_readers_writers_and_unrelated_admins_cannot_manage_project_access(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$other = Project::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$service = app(PermissionService::class);
|
||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
foreach ([[], [Permission::ProjectRead], [Permission::ProjectWrite]] as $permissions) {
|
||||
$actor = User::factory()->create();
|
||||
$service->sync($actor, $project, $permissions);
|
||||
$service->sync($actor, $other, [Permission::ProjectAdmin]);
|
||||
$this->actingAs($actor)->get(route('projects.access', $project))->assertForbidden();
|
||||
foreach ([['users', $member], ['teams', $team]] as [$kind, $assignee]) {
|
||||
$this->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => ['project:admin']])->assertForbidden();
|
||||
}
|
||||
}
|
||||
$this->assertSame(['project:read'], $service->assignmentMap(User::class, $project)[$member->id]);
|
||||
$this->assertSame(['project:write'], $service->assignmentMap(Team::class, $project)[$team->id]);
|
||||
}
|
||||
|
||||
public function test_invalid_assignments_preserve_both_user_and_team_permissions(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$project = Project::factory()->create();
|
||||
$service = app(PermissionService::class);
|
||||
foreach ([['users', User::factory()->create()], ['teams', Team::factory()->create()]] as [$kind, $assignee]) {
|
||||
$service->sync($assignee, $project, [Permission::ProjectRead]);
|
||||
foreach ([['global:admin'], ['team:admin'], ['unknown'], ['project:read', 'project:read']] as $permissions) {
|
||||
$this->actingAs($admin)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => $permissions])
|
||||
->assertSessionHasErrors('permissions.0');
|
||||
$this->assertSame(['project:read'], $service->assignmentMap($assignee::class, $project)[$assignee->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_clearing_assignments_preserves_access_from_other_sources(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($member);
|
||||
$service = app(PermissionService::class);
|
||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$this->actingAs($admin)->patch(route('projects.permissions.users.update', [$project, $member]), ['permissions' => []])
|
||||
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
||||
$this->assertTrue($service->allows($member, Permission::ProjectWrite, $project));
|
||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||
$this->patch(route('projects.permissions.teams.update', [$project, $team]), ['permissions' => []])
|
||||
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
||||
$this->assertTrue($service->allows($member, Permission::ProjectRead, $project));
|
||||
$this->assertFalse($service->allows($member, Permission::ProjectWrite, $project));
|
||||
}
|
||||
|
||||
public function test_removing_own_admin_access_redirects_to_projects_for_direct_and_team_grants(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
foreach (['users', 'teams'] as $kind) {
|
||||
$actor = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$assignee = $actor;
|
||||
if ($kind === 'teams') {
|
||||
$assignee = Team::factory()->create();
|
||||
$assignee->users()->attach($actor);
|
||||
}
|
||||
$service->sync($assignee, $project, [Permission::ProjectAdmin]);
|
||||
$this->actingAs($actor)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => []])
|
||||
->assertRedirect(route('projects.index'))->assertSessionHasNoErrors();
|
||||
$this->assertFalse($service->allows($actor, Permission::ProjectAdmin, $project));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectAccessUiTest extends TestCase
|
||||
{
|
||||
public function test_access_overview_renders_sources_empty_states_and_localization(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-access.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user