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:
2026-03-18 00:12:50 +00:00
parent 5dffd2d063
commit c89d1879bf
83 changed files with 5850 additions and 314 deletions

View 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');
});

View 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();
});

View 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();
});

View 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');
});

View 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);