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,245 @@
<?php
use App\Enums\WorkspaceUserRole;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia as Assert;
function setupClientTestUser(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];
}
// AC #1: Workers see only clients with assigned declarations
test('worker sees only clients with assigned declarations in index', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$clientWithAssignment = Client::factory()->create(['workspace_id' => $workspace->id]);
$clientWithoutAssignment = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $clientWithAssignment->id,
'assigned_to' => $worker->id,
]);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $clientWithoutAssignment->id,
'assigned_to' => null,
]);
$response = $this->actingAs($worker)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Index')
->has('clients.data', 1)
->where('clients.data.0.id', $clientWithAssignment->id)
);
});
// AC #3: Owners see all workspace clients
test('owner sees all workspace clients in index', function () {
[$owner, $workspace] = setupClientTestUser(WorkspaceUserRole::Owner);
Client::factory()->count(3)->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($owner)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Index')
->has('clients.data', 3)
);
});
// AC #3: Managers see all workspace clients
test('manager sees all workspace clients in index', function () {
[$manager, $workspace] = setupClientTestUser(WorkspaceUserRole::Manager);
Client::factory()->count(3)->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($manager)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Index')
->has('clients.data', 3)
);
});
// AC #5: Worker gets 404 accessing client with no assigned declarations
test('worker gets 404 accessing client with no assigned declarations', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
// No declarations assigned to this worker for this client
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => null,
]);
$response = $this->actingAs($worker)->get(route('clients.show', $client));
$response->assertNotFound();
});
// Worker can access client show when they have assigned declarations
test('worker can access client show when they have assigned declarations', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$response = $this->actingAs($worker)->get(route('clients.show', $client));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Show')
);
});
// AC #6: Worker gets 404 on create
test('worker gets 404 on client create', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('clients.create'));
$response->assertNotFound();
});
// AC #6: Worker gets 404 on store
test('worker gets 404 on client store', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->post(route('clients.store'), [
'company_name' => 'Test',
'legal_form' => 'sarl',
'contacts' => [['full_name' => 'Test', 'is_principal' => true]],
]);
$response->assertNotFound();
});
// AC #6: Worker gets 404 on edit
test('worker gets 404 on client edit', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->get(route('clients.edit', $client));
$response->assertNotFound();
});
// AC #6: Worker gets 404 on update
test('worker gets 404 on client update', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->put(route('clients.update', $client), [
'company_name' => 'Updated',
'legal_form' => 'sarl',
'contacts' => [
['full_name' => 'Test Contact', 'is_principal' => true],
],
]);
$response->assertNotFound();
});
// AC #6: Worker gets 404 on destroy
test('worker gets 404 on client destroy', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->delete(route('clients.destroy', $client));
$response->assertNotFound();
});
// AC #3: Manager can access all CRUD operations
test('manager can access all client CRUD operations', function () {
[$manager, $workspace] = setupClientTestUser(WorkspaceUserRole::Manager);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
// Index
$this->actingAs($manager)->get(route('clients.index'))->assertOk();
// Show
$this->actingAs($manager)->get(route('clients.show', $client))->assertOk();
// Create page
$this->actingAs($manager)->get(route('clients.create'))->assertOk();
// Edit page
$this->actingAs($manager)->get(route('clients.edit', $client))->assertOk();
});
// AC #3: Owner can access all CRUD operations
test('owner can access all client CRUD operations', function () {
[$owner, $workspace] = setupClientTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
// Index
$this->actingAs($owner)->get(route('clients.index'))->assertOk();
// Show
$this->actingAs($owner)->get(route('clients.show', $client))->assertOk();
// Create page
$this->actingAs($owner)->get(route('clients.create'))->assertOk();
// Edit page
$this->actingAs($owner)->get(route('clients.edit', $client))->assertOk();
});
// AC #10: canCreate/canEdit/canDelete props are false for Workers
test('worker gets canCreate canEdit canDelete as false in index', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', false)
->where('canEdit', false)
->where('canDelete', false)
);
});
// AC #10: canCreate/canEdit/canDelete props are true for Owners
test('owner gets canCreate canEdit canDelete as true in index', function () {
[$owner, $workspace] = setupClientTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($owner)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', true)
->where('canEdit', true)
->where('canDelete', true)
);
});

View File

@@ -0,0 +1,64 @@
<?php
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
function getMigrationInstance(): object
{
return require database_path('migrations/2026_03_14_000001_rename_member_to_worker_in_workspace_user.php');
}
test('data migration renames member to worker in workspace_user role', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
// Insert a record with the old 'member' value directly via DB
DB::table('workspace_user')->insert([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'role' => 'member',
'created_at' => now(),
'updated_at' => now(),
]);
// Verify it has 'member' role
expect(
DB::table('workspace_user')
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->value('role')
)->toBe('member');
// Run the actual migration file
getMigrationInstance()->up();
// Verify it now has 'worker' role
expect(
DB::table('workspace_user')
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->value('role')
)->toBe('worker');
});
test('data migration does not affect owner or manager roles', function () {
$owner = User::factory()->create();
$manager = User::factory()->create();
$worker = User::factory()->create();
$workspace = Workspace::factory()->create();
DB::table('workspace_user')->insert([
['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => 'owner', 'created_at' => now(), 'updated_at' => now()],
['workspace_id' => $workspace->id, 'user_id' => $manager->id, 'role' => 'manager', 'created_at' => now(), 'updated_at' => now()],
['workspace_id' => $workspace->id, 'user_id' => $worker->id, 'role' => 'member', 'created_at' => now(), 'updated_at' => now()],
]);
// Run the actual migration file
getMigrationInstance()->up();
// Owner and manager should remain unchanged
expect(DB::table('workspace_user')->where('user_id', $owner->id)->value('role'))->toBe('owner');
expect(DB::table('workspace_user')->where('user_id', $manager->id)->value('role'))->toBe('manager');
expect(DB::table('workspace_user')->where('user_id', $worker->id)->value('role'))->toBe('worker');
});

View File

@@ -36,8 +36,8 @@ test('composite index exists on messages declaration_id and created_at', functio
});
test('migration is reversible and rollback restores folder tables', function () {
// Rollback foundation migrations (3) + polymorphic update + rename migration
$this->artisan('migrate:rollback', ['--step' => 5]);
// Rollback team_invitations (1) + member-to-worker rename (1) + foundation migrations (3) + polymorphic update + rename migration
$this->artisan('migrate:rollback', ['--step' => 7]);
expect(Schema::hasTable('folders'))->toBeTrue();
expect(Schema::hasTable('declarations'))->toBeFalse();

View File

@@ -80,7 +80,7 @@ test('re-downloading updates timestamp without creating duplicates', function ()
test('download status is per-user in show endpoint', function () {
[$user, $workspace, $declaration, $media] = setupDeclarationWithMedia();
$otherUser = User::factory()->create();
$workspace->users()->attach($otherUser, ['role' => 'member']);
$workspace->users()->attach($otherUser, ['role' => 'manager']);
session(['current_workspace_id' => $workspace->id]);
MediaDownload::query()->create([

View File

@@ -0,0 +1,377 @@
<?php
use App\Enums\WorkspaceUserRole;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia as Assert;
function setupDeclarationTestUser(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];
}
// AC #2: Worker sees only assigned declarations in index
test('worker sees only assigned declarations in index', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$assignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$unassignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => null,
]);
$otherUser = User::factory()->create();
$otherAssignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $otherUser->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Index')
->has('declarations.data', 1)
->where('declarations.data.0.id', $assignedDeclaration->id)
);
});
// AC #3: Owner sees all declarations
test('owner sees all declarations in index', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->count(5)->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($owner)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Index')
->has('declarations.data', 5)
);
});
// AC #3: Manager sees all declarations
test('manager sees all declarations in index', function () {
[$manager, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Manager);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->count(3)->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($manager)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Index')
->has('declarations.data', 3)
);
});
// AC #4: Worker gets 404 accessing unassigned declaration via direct URL
test('worker gets 404 accessing unassigned declaration via direct URL', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => null,
]);
$response = $this->actingAs($worker)->get(route('declarations.show', $declaration));
$response->assertNotFound();
});
// Worker can access assigned declaration show
test('worker can access assigned declaration show', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.show', $declaration));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Show')
);
});
// AC #6: Worker gets 404 on create
test('worker gets 404 on declaration create', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('declarations.create'));
$response->assertNotFound();
});
// Worker gets 404 on store
test('worker gets 404 on declaration store', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->post(route('declarations.store'), [
'title' => 'Test',
'type' => 'vat_monthly',
'client_id' => $client->id,
'period_year' => 2026,
'period_month' => 1,
]);
$response->assertNotFound();
});
// Worker gets 404 on edit
test('worker gets 404 on declaration edit', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.edit', $declaration));
$response->assertNotFound();
});
// Worker gets 404 on update
test('worker gets 404 on declaration update', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($worker)->put(route('declarations.update', $declaration), [
'title' => 'Updated',
'type' => 'vat_monthly',
'client_id' => $client->id,
'period_year' => 2026,
'period_month' => 1,
]);
$response->assertNotFound();
});
// Worker gets 404 on destroy
test('worker gets 404 on declaration destroy', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($worker)->delete(route('declarations.destroy', $declaration));
$response->assertNotFound();
});
// Manager can access all CRUD operations
test('manager can access all declaration CRUD operations', function () {
[$manager, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Manager);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
// Index
$this->actingAs($manager)->get(route('declarations.index'))->assertOk();
// Show
$this->actingAs($manager)->get(route('declarations.show', $declaration))->assertOk();
// Create page
$this->actingAs($manager)->get(route('declarations.create'))->assertOk();
// Edit page
$this->actingAs($manager)->get(route('declarations.edit', $declaration))->assertOk();
});
// Owner can access all CRUD operations
test('owner can access all declaration CRUD operations', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
// Index
$this->actingAs($owner)->get(route('declarations.index'))->assertOk();
// Show
$this->actingAs($owner)->get(route('declarations.show', $declaration))->assertOk();
// Create page
$this->actingAs($owner)->get(route('declarations.create'))->assertOk();
// Edit page
$this->actingAs($owner)->get(route('declarations.edit', $declaration))->assertOk();
});
// AC #10: canCreate/canEdit/canDelete props false for Workers
test('worker gets canCreate canEdit canDelete as false in declarations index', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', false)
->where('canEdit', false)
->where('canDelete', false)
);
});
// AC #10: canCreate/canEdit/canDelete props true for Owners
test('owner gets canCreate canEdit canDelete as true in declarations index', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($owner)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', true)
->where('canEdit', true)
->where('canDelete', true)
);
});
// AC #9: auth.workspaceRole is shared correctly
test('auth.workspaceRole is shared correctly for each role', function () {
// Test Owner
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($owner)->get(route('declarations.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('auth.workspaceRole', 'owner')
);
// Test Manager
[$manager, $workspace2] = setupDeclarationTestUser(WorkspaceUserRole::Manager);
$response = $this->actingAs($manager)->get(route('declarations.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('auth.workspaceRole', 'manager')
);
// Test Worker
[$worker, $workspace3] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('auth.workspaceRole', 'worker')
);
});
// AC #11: Cross-workspace isolation
test('worker in workspace A cannot see declarations in workspace B', function () {
[$worker, $workspaceA] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$workspaceB = Workspace::factory()->create();
$clientB = Client::factory()->create(['workspace_id' => $workspaceB->id]);
$declarationInB = Declaration::factory()->create([
'workspace_id' => $workspaceB->id,
'client_id' => $clientB->id,
'assigned_to' => $worker->id,
]);
// Worker should not see workspace B declarations in their index
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->has('declarations.data', 0)
);
// Worker should get 404 trying to access workspace B declaration directly
$response = $this->actingAs($worker)->get(route('declarations.show', $declarationInB));
$response->assertNotFound();
});
// canMention is false for workers in declaration show
test('worker gets canMention as false in declaration show', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.show', $declaration));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canMention', false)
->where('canEdit', false)
->where('canDelete', false)
);
});
// canMention is true for owners/managers in declaration show
test('owner gets canMention as true in declaration show', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($owner)->get(route('declarations.show', $declaration));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canMention', true)
->where('canEdit', true)
->where('canDelete', true)
);
});

View File

@@ -13,7 +13,7 @@ function setupMentionScenario(string $role = 'owner'): array
$target = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($sender, ['role' => $role]);
$workspace->users()->attach($target, ['role' => 'member']);
$workspace->users()->attach($target, ['role' => 'worker']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
@@ -52,9 +52,9 @@ test('manager can mention a workspace user', function () {
Notification::assertSentTo($target, DeclarationMentionNotification::class);
});
test('member cannot mention a workspace user', function () {
test('worker cannot mention a workspace user', function () {
Notification::fake();
[$sender, $target, $workspace, $declaration] = setupMentionScenario('member');
[$sender, $target, $workspace, $declaration] = setupMentionScenario('worker');
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($sender)->post(route('declarations.mentions.store', $declaration), [

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

View File

@@ -0,0 +1,123 @@
<?php
use App\Enums\WorkspaceUserRole;
use App\Models\Client;
use App\Models\User;
use App\Models\Workspace;
use Spatie\Activitylog\Models\Activity;
function setupSwitchTestUser(string $role, int $workspaceCount = 2): array
{
$user = User::factory()->create();
$workspaces = [];
for ($i = 0; $i < $workspaceCount; $i++) {
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => [],
]);
$workspaces[] = $workspace;
}
return [$user, $workspaces];
}
test('owner with multiple workspaces can switch workspace', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$response->assertRedirect(route('dashboard'));
expect(session('current_workspace_id'))->toBe($workspaces[1]->id);
});
test('workspace switching logs activity with previous and new workspace ids', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$log = Activity::latest('id')->first();
expect($log->description)->toBe('Switched workspace')
->and($log->causer_id)->toBe($user->id)
->and($log->properties['previous_workspace_id'])->toBe($workspaces[0]->id)
->and($log->properties['new_workspace_id'])->toBe($workspaces[1]->id);
});
test('user cannot switch to a workspace they do not belong to', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Worker, 1);
$otherWorkspace = Workspace::factory()->create();
$response = $this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $otherWorkspace->id,
]);
$response->assertRedirect(route('dashboard'));
expect(session('current_workspace_id'))->toBe($workspaces[0]->id)
->and(Activity::where('description', 'Switched workspace')->count())->toBe(0);
});
test('switching updates auth shared props on next page load', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertInertia(fn ($page) => $page
->where('auth.currentWorkspace.id', $workspaces[1]->id)
->where('auth.workspaceRole', 'owner')
->has('auth.workspaceSwitchUrl')
);
});
test('user with single workspace can post switch with same workspace id without error', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Worker, 1);
$response = $this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[0]->id,
]);
$response->assertRedirect(route('dashboard'));
expect(session('current_workspace_id'))->toBe($workspaces[0]->id);
});
test('cross-workspace isolation: after switching, data queries return only new workspace data', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$clientA = Client::factory()->create(['workspace_id' => $workspaces[0]->id]);
$clientB = Client::factory()->create(['workspace_id' => $workspaces[1]->id]);
$this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$response = $this->actingAs($user)->get(route('clients.index'));
$response->assertInertia(fn ($page) => $page
->has('clients.data', 1)
->where('clients.data.0.id', $clientB->id)
);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Concerns\AuthorizesPermissions;
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Models\User;
use App\Models\Workspace;
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);
// Create a testable class that uses the trait
function createPermissionChecker(): object
{
return new class
{
use AuthorizesPermissions;
public function check(string $permission): void
{
$this->authorizePermission($permission);
}
};
}
function setupWorkspaceUser(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];
}
test('owner always passes permission check', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Owner);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanManageTeam);
expect(true)->toBeTrue(); // No exception thrown
});
test('worker always fails permission check with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Worker);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanManageTeam);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
test('manager with granted permission passes', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, [
Permission::CanViewActivityLogs => true,
]);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanViewActivityLogs);
expect(true)->toBeTrue(); // No exception thrown
});
test('manager with denied permission fails with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanManageTeam);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
test('manager with unknown permission key defaults to false and fails with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, []);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check('some_unknown_permission');
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
test('manager with empty permissions fails with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, []);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanConfigurePortal);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);