feat: complete Epic 1 — team management & permission system
- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration - Story 1.2: Team page with member list, invitation system with queued email - Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging - Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal) - Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping - Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching - 83 new/modified files, 182 tests passing with zero regressions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
214
tests/Feature/Team/ManageTeamTest.php
Normal file
214
tests/Feature/Team/ManageTeamTest.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Mail\TeamInvitationMail;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function setupTeamUser(string $role, array $permissions = []): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace];
|
||||
}
|
||||
|
||||
// ── Team Index ──────────────────────────────────────────────
|
||||
|
||||
test('owner can view team index page', function () {
|
||||
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('team.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('team/Index')
|
||||
->has('members')
|
||||
->has('pendingInvitations')
|
||||
->where('canManageTeam', true)
|
||||
->has('inviteUrl')
|
||||
->has('roles')
|
||||
);
|
||||
});
|
||||
|
||||
test('manager with can_manage_team can view team index page', function () {
|
||||
[$user] = setupTeamUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('team.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('team/Index')
|
||||
->where('canManageTeam', true)
|
||||
);
|
||||
});
|
||||
|
||||
test('manager without can_manage_team can view team index but canManageTeam is false', function () {
|
||||
[$user] = setupTeamUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('team.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('team/Index')
|
||||
->where('canManageTeam', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('worker receives 404 on team index', function () {
|
||||
[$user] = setupTeamUser(WorkspaceUserRole::Worker);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('team.index'));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Invite Member ───────────────────────────────────────────
|
||||
|
||||
test('owner can invite a new member', function () {
|
||||
Mail::fake();
|
||||
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'newmember@example.com',
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Invitation envoyée');
|
||||
|
||||
expect(TeamInvitation::where('workspace_id', $workspace->id)
|
||||
->where('email', 'newmember@example.com')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
|
||||
Mail::assertQueued(TeamInvitationMail::class, function ($mail) {
|
||||
return $mail->hasTo('newmember@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
test('manager with can_manage_team can invite a new member', function () {
|
||||
Mail::fake();
|
||||
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'newmanager@example.com',
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Invitation envoyée');
|
||||
|
||||
expect(TeamInvitation::where('workspace_id', $workspace->id)
|
||||
->where('email', 'newmanager@example.com')
|
||||
->where('role', 'manager')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('manager without permission gets 404 on invite', function () {
|
||||
[$user] = setupTeamUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'blocked@example.com',
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('worker gets 404 on invite', function () {
|
||||
[$user] = setupTeamUser(WorkspaceUserRole::Worker);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'blocked@example.com',
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('cannot invite email already in workspace', function () {
|
||||
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
|
||||
$existingMember = User::factory()->create(['email' => 'existing@example.com']);
|
||||
$workspace->users()->attach($existingMember->id, ['role' => 'worker']);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'existing@example.com',
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
});
|
||||
|
||||
test('invitation creates team invitation record with correct data', function () {
|
||||
Mail::fake();
|
||||
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
|
||||
|
||||
$this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'record@example.com',
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$invitation = TeamInvitation::where('email', 'record@example.com')->first();
|
||||
|
||||
expect($invitation)->not->toBeNull()
|
||||
->and($invitation->workspace_id)->toBe($workspace->id)
|
||||
->and($invitation->role)->toBe('manager')
|
||||
->and($invitation->invited_by)->toBe($user->id)
|
||||
->and($invitation->token)->not->toBeEmpty()
|
||||
->and($invitation->expires_at)->not->toBeNull()
|
||||
->and($invitation->accepted_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('invitation sends email', function () {
|
||||
Mail::fake();
|
||||
[$user] = setupTeamUser(WorkspaceUserRole::Owner);
|
||||
|
||||
$this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'mailto@example.com',
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
Mail::assertQueued(TeamInvitationMail::class, function ($mail) {
|
||||
return $mail->hasTo('mailto@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
test('cannot send duplicate active invitation to same email', function () {
|
||||
Mail::fake();
|
||||
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
|
||||
|
||||
// Create an existing active invitation
|
||||
TeamInvitation::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'email' => 'duplicate@example.com',
|
||||
'role' => 'worker',
|
||||
'invited_by' => $user->id,
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('team.invite'), [
|
||||
'email' => 'duplicate@example.com',
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
});
|
||||
248
tests/Feature/Team/PermissionToggleTest.php
Normal file
248
tests/Feature/Team/PermissionToggleTest.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceUser;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
function setupPermTestUser(string $role, array $permissions = []): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace];
|
||||
}
|
||||
|
||||
function createPermWorkspaceMember(Workspace $workspace, string $role, array $permissions = []): User
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$workspace->users()->attach($member->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
function getPermWorkspaceUserId(Workspace $workspace, User $user): int
|
||||
{
|
||||
return WorkspaceUser::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail()
|
||||
->id;
|
||||
}
|
||||
|
||||
// ── 4.1: Owner can update Manager permissions ──
|
||||
|
||||
test('owner can update manager permissions', function () {
|
||||
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => false,
|
||||
]);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $manager);
|
||||
|
||||
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Permissions mises à jour');
|
||||
|
||||
$updatedPivot = WorkspaceUser::find($pivotId);
|
||||
expect($updatedPivot->permissions[Permission::CanManageTeam])->toBeTrue()
|
||||
->and($updatedPivot->permissions[Permission::CanViewActivityLogs])->toBeTrue()
|
||||
->and($updatedPivot->permissions[Permission::CanConfigurePortal])->toBeTrue();
|
||||
});
|
||||
|
||||
// ── 4.2: Manager cannot update permissions (even with can_manage_team) ──
|
||||
|
||||
test('manager with can_manage_team cannot update permissions', function () {
|
||||
[$manager, $workspace] = setupPermTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
$otherManager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
]);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $otherManager);
|
||||
|
||||
$response = $this->actingAs($manager)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── 4.3: Worker cannot access permissions endpoint ──
|
||||
|
||||
test('worker cannot access permissions endpoint', function () {
|
||||
[$worker, $workspace] = setupPermTestUser(WorkspaceUserRole::Worker);
|
||||
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $manager);
|
||||
|
||||
$response = $this->actingAs($worker)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── 4.4: Cannot update Owner's permissions ──
|
||||
|
||||
test('cannot update owner permissions', function () {
|
||||
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
$otherOwner = createPermWorkspaceMember($workspace, WorkspaceUserRole::Owner);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $otherOwner);
|
||||
|
||||
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── 4.5: Cannot update Worker's permissions ──
|
||||
|
||||
test('cannot update worker permissions', function () {
|
||||
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createPermWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── 4.6: Invalid permission key is rejected ──
|
||||
|
||||
test('invalid permission key is rejected', function () {
|
||||
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $manager);
|
||||
|
||||
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
'invalid_permission_key' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('permissions');
|
||||
});
|
||||
|
||||
// ── 4.7: Activity log entry created on permission change ──
|
||||
|
||||
test('activity log entry created on permission change', function () {
|
||||
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => false,
|
||||
]);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $manager);
|
||||
|
||||
$this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$log = Activity::latest('id')->first();
|
||||
expect($log->description)->toBe('permissions_updated')
|
||||
->and($log->properties['target_user'])->toBe($manager->name)
|
||||
->and($log->properties['old_permissions'][Permission::CanManageTeam])->toBeFalse()
|
||||
->and($log->properties['new_permissions'][Permission::CanManageTeam])->toBeTrue();
|
||||
});
|
||||
|
||||
// ── 4.8: Toggling can_manage_team off removes invite capability on next page load ──
|
||||
|
||||
test('toggling can_manage_team off removes invite capability on next page load', function () {
|
||||
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => false,
|
||||
]);
|
||||
$pivotId = getPermWorkspaceUserId($workspace, $manager);
|
||||
|
||||
// Toggle can_manage_team OFF
|
||||
$this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => false,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => false,
|
||||
],
|
||||
]);
|
||||
|
||||
// Now load team page as the manager — canManageTeam should be false
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
$response = $this->actingAs($manager)->get(route('team.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('team/Index')
|
||||
->where('canManageTeam', false)
|
||||
);
|
||||
});
|
||||
|
||||
// ── Cross-workspace isolation: Owner cannot update permissions in another workspace ──
|
||||
|
||||
test('owner cannot update manager permissions in another workspace', function () {
|
||||
[$owner, $workspaceA] = setupPermTestUser(WorkspaceUserRole::Owner);
|
||||
|
||||
// Create a separate workspace B with its own manager
|
||||
$workspaceB = Workspace::factory()->create();
|
||||
$managerB = User::factory()->create();
|
||||
$workspaceB->users()->attach($managerB->id, [
|
||||
'role' => WorkspaceUserRole::Manager,
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => false,
|
||||
],
|
||||
]);
|
||||
$pivotIdB = WorkspaceUser::where('workspace_id', $workspaceB->id)
|
||||
->where('user_id', $managerB->id)
|
||||
->firstOrFail()
|
||||
->id;
|
||||
|
||||
// Session is set to workspace A — attempt to update manager in workspace B
|
||||
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotIdB), [
|
||||
'permissions' => [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
Permission::CanConfigurePortal => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify permissions were NOT changed
|
||||
$pivot = WorkspaceUser::find($pivotIdB);
|
||||
expect($pivot->permissions[Permission::CanManageTeam])->toBeFalse();
|
||||
});
|
||||
153
tests/Feature/Team/TeamMemberRemovalTest.php
Normal file
153
tests/Feature/Team/TeamMemberRemovalTest.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceUser;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
function setupRemovalTestUser(string $role, array $permissions = []): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace];
|
||||
}
|
||||
|
||||
function createRemovalTestMember(Workspace $workspace, string $role, array $permissions = []): User
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$workspace->users()->attach($member->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
function getRemovalPivotId(Workspace $workspace, User $user): int
|
||||
{
|
||||
return WorkspaceUser::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail()
|
||||
->id;
|
||||
}
|
||||
|
||||
// ── Owner can remove a member ──────────────────────────────
|
||||
|
||||
test('owner can remove a member', function () {
|
||||
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getRemovalPivotId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($owner)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Membre retiré');
|
||||
|
||||
// Pivot row should be deleted
|
||||
expect(WorkspaceUser::find($pivotId))->toBeNull();
|
||||
});
|
||||
|
||||
// ── Owner cannot remove themselves ─────────────────────────
|
||||
|
||||
test('owner cannot remove themselves', function () {
|
||||
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
|
||||
$pivotId = getRemovalPivotId($workspace, $owner);
|
||||
|
||||
$response = $this->actingAs($owner)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Manager with can_manage_team can remove worker ─────────
|
||||
|
||||
test('manager with can_manage_team can remove worker', function () {
|
||||
[$manager, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getRemovalPivotId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($manager)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Membre retiré');
|
||||
});
|
||||
|
||||
// ── Manager with can_manage_team cannot remove owner ───────
|
||||
|
||||
test('manager with can_manage_team cannot remove owner', function () {
|
||||
[$manager, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
$owner = createRemovalTestMember($workspace, WorkspaceUserRole::Owner);
|
||||
$pivotId = getRemovalPivotId($workspace, $owner);
|
||||
|
||||
$response = $this->actingAs($manager)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Manager without can_manage_team gets 404 ───────────────
|
||||
|
||||
test('manager without can_manage_team gets 404 on remove', function () {
|
||||
[$manager, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
]);
|
||||
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getRemovalPivotId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($manager)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Worker gets 404 on remove attempt ──────────────────────
|
||||
|
||||
test('worker gets 404 on remove attempt', function () {
|
||||
[$worker, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Worker);
|
||||
$otherWorker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getRemovalPivotId($workspace, $otherWorker);
|
||||
|
||||
$response = $this->actingAs($worker)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Member removal is logged ───────────────────────────────
|
||||
|
||||
test('member removal is logged in activity log', function () {
|
||||
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getRemovalPivotId($workspace, $worker);
|
||||
|
||||
$this->actingAs($owner)->delete(route('team.remove', $pivotId));
|
||||
|
||||
$log = Activity::latest('id')->first();
|
||||
expect($log->description)->toBe('member_removed')
|
||||
->and($log->properties['target_user'])->toBe($worker->name)
|
||||
->and($log->properties['target_email'])->toBe($worker->email)
|
||||
->and($log->properties['role'])->toBe('worker');
|
||||
});
|
||||
|
||||
// ── Removed user account still exists ──────────────────────
|
||||
|
||||
test('removed user account still exists after removal', function () {
|
||||
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getRemovalPivotId($workspace, $worker);
|
||||
|
||||
$this->actingAs($owner)->delete(route('team.remove', $pivotId));
|
||||
|
||||
// User record still exists
|
||||
expect(User::find($worker->id))->not->toBeNull();
|
||||
// But pivot row is deleted
|
||||
expect(WorkspaceUser::find($pivotId))->toBeNull();
|
||||
});
|
||||
217
tests/Feature/Team/TeamRoleAssignmentTest.php
Normal file
217
tests/Feature/Team/TeamRoleAssignmentTest.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceUser;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
function setupRoleTestUser(string $role, array $permissions = []): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace];
|
||||
}
|
||||
|
||||
function createWorkspaceMember(Workspace $workspace, string $role, array $permissions = []): User
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$workspace->users()->attach($member->id, [
|
||||
'role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
function getWorkspaceUserId(Workspace $workspace, User $user): int
|
||||
{
|
||||
return WorkspaceUser::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail()
|
||||
->id;
|
||||
}
|
||||
|
||||
// ── Owner can change roles ──────────────────────────────────
|
||||
|
||||
test('owner can change a member role from worker to manager', function () {
|
||||
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getWorkspaceUserId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Rôle mis à jour');
|
||||
|
||||
$updatedPivot = WorkspaceUser::find($pivotId);
|
||||
expect($updatedPivot->role->is(WorkspaceUserRole::Manager))->toBeTrue();
|
||||
});
|
||||
|
||||
test('owner can change a member role from manager to worker', function () {
|
||||
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
|
||||
$manager = createWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
Permission::CanViewActivityLogs => true,
|
||||
]);
|
||||
$pivotId = getWorkspaceUserId($workspace, $manager);
|
||||
|
||||
$response = $this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Rôle mis à jour');
|
||||
|
||||
$updatedPivot = WorkspaceUser::find($pivotId);
|
||||
expect($updatedPivot->role->is(WorkspaceUserRole::Worker))->toBeTrue();
|
||||
});
|
||||
|
||||
// ── Role change resets permissions ──────────────────────────
|
||||
|
||||
test('role change resets permissions to defaults for new role', function () {
|
||||
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getWorkspaceUserId($workspace, $worker);
|
||||
|
||||
$this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$updatedPivot = WorkspaceUser::find($pivotId);
|
||||
$managerDefaults = config('permissions.defaults.manager');
|
||||
expect($updatedPivot->permissions)->toBe($managerDefaults);
|
||||
|
||||
// Now change back to worker — permissions should be empty
|
||||
$this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$updatedPivot->refresh();
|
||||
expect($updatedPivot->permissions)->toBe([]);
|
||||
});
|
||||
|
||||
// ── Owner cannot change own role ────────────────────────────
|
||||
|
||||
test('owner cannot change own role', function () {
|
||||
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
|
||||
$pivotId = getWorkspaceUserId($workspace, $owner);
|
||||
|
||||
$response = $this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Manager with can_manage_team can change worker role ─────
|
||||
|
||||
test('manager with can_manage_team can change worker role', function () {
|
||||
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getWorkspaceUserId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Rôle mis à jour');
|
||||
});
|
||||
|
||||
// ── Manager with can_manage_team can change another manager role ─
|
||||
|
||||
test('manager with can_manage_team can change another manager role', function () {
|
||||
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
$otherManager = createWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
]);
|
||||
$pivotId = getWorkspaceUserId($workspace, $otherManager);
|
||||
|
||||
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Rôle mis à jour');
|
||||
|
||||
$updatedPivot = WorkspaceUser::find($pivotId);
|
||||
expect($updatedPivot->role->is(WorkspaceUserRole::Worker))->toBeTrue();
|
||||
});
|
||||
|
||||
// ── Manager with can_manage_team cannot change owner role ───
|
||||
|
||||
test('manager with can_manage_team cannot change owner role', function () {
|
||||
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => true,
|
||||
]);
|
||||
$owner = createWorkspaceMember($workspace, WorkspaceUserRole::Owner);
|
||||
$pivotId = getWorkspaceUserId($workspace, $owner);
|
||||
|
||||
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'worker',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Manager without can_manage_team gets 404 ───────────────
|
||||
|
||||
test('manager without can_manage_team gets 404 on role change', function () {
|
||||
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
|
||||
Permission::CanManageTeam => false,
|
||||
]);
|
||||
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getWorkspaceUserId($workspace, $worker);
|
||||
|
||||
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Worker gets 404 on role change attempt ──────────────────
|
||||
|
||||
test('worker gets 404 on role change attempt', function () {
|
||||
[$worker, $workspace] = setupRoleTestUser(WorkspaceUserRole::Worker);
|
||||
$otherWorker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getWorkspaceUserId($workspace, $otherWorker);
|
||||
|
||||
$response = $this->actingAs($worker)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ── Activity log ────────────────────────────────────────────
|
||||
|
||||
test('role change is logged in activity log', function () {
|
||||
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
|
||||
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
|
||||
$pivotId = getWorkspaceUserId($workspace, $worker);
|
||||
|
||||
$this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$log = Activity::latest('id')->first();
|
||||
expect($log->description)->toBe('role_changed')
|
||||
->and($log->properties['target_user'])->toBe($worker->name)
|
||||
->and($log->properties['old_role'])->toBe('worker')
|
||||
->and($log->properties['new_role'])->toBe('manager');
|
||||
});
|
||||
68
tests/Feature/Team/WorkspaceScopeTest.php
Normal file
68
tests/Feature/Team/WorkspaceScopeTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Concerns\HasWorkspaceScope;
|
||||
use App\Models\Client;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
function createScopeChecker(): object
|
||||
{
|
||||
return new class
|
||||
{
|
||||
use HasWorkspaceScope;
|
||||
|
||||
public function getWorkspace(): \App\Models\Workspace
|
||||
{
|
||||
return $this->currentWorkspace();
|
||||
}
|
||||
|
||||
public function checkAccess(\Illuminate\Database\Eloquent\Model $resource): void
|
||||
{
|
||||
$this->authorizeWorkspaceAccess($resource);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('currentWorkspace resolves workspace from session', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, ['role' => 'owner']);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$checker = createScopeChecker();
|
||||
$resolved = $checker->getWorkspace();
|
||||
|
||||
expect($resolved->id)->toBe($workspace->id);
|
||||
});
|
||||
|
||||
test('currentWorkspace fails when user not in workspace', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$checker = createScopeChecker();
|
||||
$checker->getWorkspace();
|
||||
})->throws(Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
|
||||
test('authorizeWorkspaceAccess passes for matching workspace', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$checker = createScopeChecker();
|
||||
$checker->checkAccess($client);
|
||||
|
||||
expect(true)->toBeTrue(); // No exception thrown
|
||||
});
|
||||
|
||||
test('authorizeWorkspaceAccess aborts 404 for mismatched workspace', function () {
|
||||
$workspace1 = Workspace::factory()->create();
|
||||
$workspace2 = Workspace::factory()->create();
|
||||
$client = Client::factory()->create(['workspace_id' => $workspace1->id]);
|
||||
session(['current_workspace_id' => $workspace2->id]);
|
||||
|
||||
$checker = createScopeChecker();
|
||||
$checker->checkAccess($client);
|
||||
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
|
||||
Reference in New Issue
Block a user