From 051db1842dfe3717c814872209c77af962b87720 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 22 Feb 2026 17:19:19 +0100 Subject: [PATCH] =?UTF-8?q?feat(107):=20implement=20Workspace=20Chooser=20?= =?UTF-8?q?v1=20=E2=80=94=20all=2040=20tasks=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 107: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point ## Core changes - Refactor EnsureWorkspaceSelected middleware: 7-step algorithm with auto-resume (single membership + last_workspace_id), stale session detection, ?choose=1 forced chooser, workspace-optional path bypass - Create WorkspaceRedirectResolver for DRY tenant-count branching (0→managed-tenants, 1→tenant-dashboard, >1→choose-tenant) - Add WorkspaceAutoSelected + WorkspaceSelected audit enum cases - Rewrite ChooseWorkspace page: role badges, tenant counts, wire:click selection, audit logging, WorkspaceRedirectResolver - Add 'Switch workspace' user menu item in AdminPanelProvider - Rewrite SwitchWorkspaceController with audit + resolver - Replace inline tenant branching in routes/web.php with resolver ## New test files (6) - WorkspaceRedirectResolverTest (5 tests Spec 107: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point ## Core changes - Refactor EnsureWorkspaceSelected middleware: 7-step algorithmst ## Core changes - Refactor EnsureWorkspaceSelected middleware: 7-stepes - Refactor Ensng auto-resume (single membership + last_workspace_id), stale sessioid detection, ?choose=1 forced chooser, w (security invariant preserve- Create WorkspaceRedirectResolver for DRY tenant-count branching (0→managed-tenants, 1→tenant-dashboapped (8163 assertions) --- app/Filament/Pages/ChooseWorkspace.php | 117 +++--- .../Controllers/SwitchWorkspaceController.php | 46 ++- .../Middleware/EnsureWorkspaceSelected.php | 194 ++++++++-- app/Providers/Filament/AdminPanelProvider.php | 20 + app/Support/Audit/AuditActionId.php | 4 + .../Workspaces/WorkspaceRedirectResolver.php | 68 ++++ .../filament/pages/choose-workspace.blade.php | 73 ++-- routes/web.php | 23 +- specs/107-workspace-chooser/tasks.md | 80 ++-- ...oChooseTenantWhenWorkspaceSelectedTest.php | 11 +- ...oseWorkspaceWhenMultipleWorkspacesTest.php | 31 +- tests/Feature/OpsUx/OperateHubShellTest.php | 5 +- .../AuthorizationSemanticsTest.php | 3 +- .../LegacyRedirectTest.php | 7 +- ...iderConnectionAuthorizationSpec081Test.php | 4 +- .../ProviderConnectionAuthorizationTest.php | 9 + .../TenantlessListRouteTest.php | 4 +- .../RequiredPermissionsAccessTest.php | 4 +- .../RequiredPermissionsSidebarTest.php | 6 +- ...rkspaceManagedTenantAdminMigrationTest.php | 8 +- .../Spec085/DenyAsNotFoundSemanticsTest.php | 6 +- .../Workspaces/ChooseWorkspacePageTest.php | 297 +++++++++++++++ .../EnsureWorkspaceSelectedMiddlewareTest.php | 358 ++++++++++++++++++ ...sToTenantRegistrationWhenNoTenantsTest.php | 12 +- .../Workspaces/WorkspaceAuditTrailTest.php | 211 +++++++++++ .../WorkspaceRedirectResolverTest.php | 121 ++++++ .../WorkspaceSwitchUserMenuTest.php | 73 ++++ 27 files changed, 1566 insertions(+), 229 deletions(-) create mode 100644 app/Support/Workspaces/WorkspaceRedirectResolver.php create mode 100644 tests/Feature/Workspaces/ChooseWorkspacePageTest.php create mode 100644 tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceAuditTrailTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index ec0ffe7..e4dee0a 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -7,10 +7,11 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Services\Audit\WorkspaceAuditLogger; +use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceIntendedUrl; -use Filament\Actions\Action; -use Filament\Forms\Components\TextInput; +use App\Support\Workspaces\WorkspaceRedirectResolver; use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Database\Eloquent\Collection; @@ -30,33 +31,18 @@ class ChooseWorkspace extends Page protected string $view = 'filament.pages.choose-workspace'; /** - * @return array + * Workspace roles keyed by workspace_id. + * + * @var array + */ + public array $workspaceRoles = []; + + /** + * @return array<\Filament\Actions\Action> */ protected function getHeaderActions(): array { - return [ - Action::make('createWorkspace') - ->label('Create workspace') - ->modalHeading('Create workspace') - ->visible(function (): bool { - $user = auth()->user(); - - return $user instanceof User - && $user->can('create', Workspace::class); - }) - ->form([ - TextInput::make('name') - ->required() - ->maxLength(255), - TextInput::make('slug') - ->helperText('Optional. Used in URLs if set.') - ->maxLength(255) - ->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug']) - ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) - ->dehydrated(fn ($state) => filled($state)), - ]) - ->action(fn (array $data) => $this->createWorkspace($data)), - ]; + return []; } /** @@ -70,15 +56,28 @@ public function getWorkspaces(): Collection return Workspace::query()->whereRaw('1 = 0')->get(); } - return Workspace::query() + $workspaces = Workspace::query() ->whereIn('id', function ($query) use ($user): void { $query->from('workspace_memberships') ->select('workspace_id') ->where('user_id', $user->getKey()); }) ->whereNull('archived_at') + ->withCount(['tenants' => function ($query): void { + $query->where('status', 'active'); + }]) ->orderBy('name') ->get(); + + // Build roles map from memberships. + $memberships = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->whereIn('workspace_id', $workspaces->pluck('id')) + ->pluck('role', 'workspace_id'); + + $this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all(); + + return $workspaces; } public function selectWorkspace(int $workspaceId): void @@ -105,11 +104,35 @@ public function selectWorkspace(int $workspaceId): void abort(404); } + $prevWorkspaceId = $context->currentWorkspaceId(request()); + $context->setCurrentWorkspace($workspace, $user, request()); + // Audit: manual workspace selection. + /** @var WorkspaceAuditLogger $logger */ + $logger = app(WorkspaceAuditLogger::class); + + $logger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSelected->value, + context: [ + 'metadata' => [ + 'method' => 'manual', + 'reason' => 'chooser', + 'prev_workspace_id' => $prevWorkspaceId, + ], + ], + actor: $user, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + $intendedUrl = WorkspaceIntendedUrl::consume(request()); - $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); + + $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); } /** @@ -147,41 +170,9 @@ public function createWorkspace(array $data): void $intendedUrl = WorkspaceIntendedUrl::consume(request()); - $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); - } + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); - private function redirectAfterWorkspaceSelected(User $user): string - { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - - if ($workspaceId === null) { - return self::getUrl(); - } - - $workspace = Workspace::query()->whereKey($workspaceId)->first(); - - if (! $workspace instanceof Workspace) { - return self::getUrl(); - } - - $tenantsQuery = $user->tenants() - ->where('workspace_id', $workspace->getKey()) - ->where('status', 'active'); - - $tenantCount = (int) $tenantsQuery->count(); - - if ($tenantCount === 0) { - return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); - } - - if ($tenantCount === 1) { - $tenant = $tenantsQuery->first(); - - if ($tenant !== null) { - return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); - } - } - - return ChooseTenant::getUrl(); + $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); } } diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 956077d..bedfa44 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -4,12 +4,13 @@ namespace App\Http\Controllers; -use App\Filament\Pages\ChooseTenant; -use App\Filament\Pages\TenantDashboard; use App\Models\User; use App\Models\Workspace; +use App\Services\Audit\WorkspaceAuditLogger; +use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceIntendedUrl; +use App\Support\Workspaces\WorkspaceRedirectResolver; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -43,32 +44,37 @@ public function __invoke(Request $request): RedirectResponse abort(404); } + $prevWorkspaceId = $context->currentWorkspaceId($request); + $context->setCurrentWorkspace($workspace, $user, $request); + /** @var WorkspaceAuditLogger $auditLogger */ + $auditLogger = app(WorkspaceAuditLogger::class); + + $auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSelected->value, + context: [ + 'metadata' => [ + 'method' => 'manual', + 'reason' => 'context_bar', + 'prev_workspace_id' => $prevWorkspaceId, + ], + ], + actor: $user, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + $intendedUrl = WorkspaceIntendedUrl::consume($request); if ($intendedUrl !== null) { return redirect()->to($intendedUrl); } - $tenantsQuery = $user->tenants() - ->where('workspace_id', $workspace->getKey()) - ->where('status', 'active'); + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); - $tenantCount = (int) $tenantsQuery->count(); - - if ($tenantCount === 0) { - return redirect()->route('admin.onboarding'); - } - - if ($tenantCount === 1) { - $tenant = $tenantsQuery->first(); - - if ($tenant !== null) { - return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); - } - } - - return redirect()->to(ChooseTenant::getUrl()); + return redirect()->to($resolver->resolve($workspace, $user)); } } diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index 0ab9470..e9fdfa7 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -1,17 +1,20 @@ route()?->getName(); if (is_string($routeName) && str_contains($routeName, '.auth.')) { @@ -31,10 +44,12 @@ public function handle(Request $request, Closure $next): Response $path = '/'.ltrim($request->path(), '/'); + // --- Step 1: workspace-optional bypass --- if ($this->isWorkspaceOptionalPath($request, $path)) { return $next($request); } + // Tenant-scoped routes are handled separately. if (str_starts_with($path, '/admin/t/')) { return $next($request); } @@ -48,44 +63,105 @@ public function handle(Request $request, Closure $next): Response /** @var WorkspaceContext $context */ $context = app(WorkspaceContext::class); - $workspace = $context->resolveInitialWorkspaceFor($user, $request); - - if ($workspace !== null) { - return $next($request); + // --- Step 2: forced chooser via ?choose=1 --- + if ($request->query('choose') === '1') { + return $this->redirectToChooser(); } - $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); + // --- Step 3: validate active session --- + $currentId = $context->currentWorkspaceId($request); - $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') - ? $membershipQuery - ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') - ->whereNull('workspaces.archived_at') - ->exists() - : $membershipQuery->exists(); + if ($currentId !== null) { + $workspace = Workspace::query()->whereKey($currentId)->first(); - $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class); + if ( + $workspace instanceof Workspace + && empty($workspace->archived_at) + && $context->isMember($user, $workspace) + ) { + return $next($request); + } - if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) { - abort(404); + // Stale session — clear and warn. + $this->clearStaleSession($context, $user, $request, $workspace); + + return $this->redirectToChooser(); } - if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) { - abort(404); + // --- Step 4: load selectable workspace memberships --- + $selectableMemberships = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->select('workspace_memberships.*') + ->get(); + + // --- Step 5: single membership auto-resume --- + if ($selectableMemberships->count() === 1) { + /** @var WorkspaceMembership $membership */ + $membership = $selectableMemberships->first(); + $workspace = Workspace::query()->whereKey($membership->workspace_id)->first(); + + if ($workspace instanceof Workspace) { + $context->setCurrentWorkspace($workspace, $user, $request); + + $this->emitAuditEvent( + workspace: $workspace, + user: $user, + actionId: AuditActionId::WorkspaceAutoSelected, + method: 'auto', + reason: 'single_membership', + ); + + return $this->redirectViaTenantBranching($workspace, $user); + } } - if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/provider-connections')) { - abort(404); + // --- Step 6: last_workspace_id auto-resume --- + if ($user->last_workspace_id !== null) { + $lastWorkspace = Workspace::query()->whereKey($user->last_workspace_id)->first(); + + if ( + $lastWorkspace instanceof Workspace + && empty($lastWorkspace->archived_at) + && $context->isMember($user, $lastWorkspace) + ) { + $context->setCurrentWorkspace($lastWorkspace, $user, $request); + + $this->emitAuditEvent( + workspace: $lastWorkspace, + user: $user, + actionId: AuditActionId::WorkspaceAutoSelected, + method: 'auto', + reason: 'last_used', + ); + + return $this->redirectViaTenantBranching($lastWorkspace, $user); + } + + // Stale last_workspace_id — clear and warn. + $workspaceName = $lastWorkspace?->name; + $user->forceFill(['last_workspace_id' => null])->save(); + + if ($workspaceName !== null) { + Notification::make() + ->title("Your access to {$workspaceName} was removed.") + ->danger() + ->send(); + } } - $target = ($hasAnyActiveMembership || $canCreateWorkspace) - ? '/admin/choose-workspace' - : '/admin/no-access'; - - if ($target === '/admin/choose-workspace') { + // --- Step 7: fallback to chooser --- + if ($selectableMemberships->isNotEmpty()) { WorkspaceIntendedUrl::storeFromRequest($request); } - return new HttpResponse('', 302, ['Location' => $target]); + $canCreate = $user->can('create', Workspace::class); + $target = ($selectableMemberships->isNotEmpty() || $canCreate) + ? '/admin/choose-workspace' + : '/admin/no-access'; + + return new \Illuminate\Http\Response('', 302, ['Location' => $target]); } private function isWorkspaceOptionalPath(Request $request, string $path): bool @@ -110,12 +186,64 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; } - private function isOperateHubPath(string $path): bool + private function redirectToChooser(): Response { - return in_array($path, [ - '/admin/operations', - '/admin/alerts', - '/admin/audit-log', - ], true); + return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); + } + + private function redirectViaTenantBranching(Workspace $workspace, User $user): Response + { + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); + + $url = $resolver->resolve($workspace, $user); + + return new \Illuminate\Http\Response('', 302, ['Location' => $url]); + } + + private function clearStaleSession(WorkspaceContext $context, User $user, Request $request, ?Workspace $workspace): void + { + $workspaceName = $workspace?->name; + + $session = $request->hasSession() ? $request->session() : session(); + $session->forget(WorkspaceContext::SESSION_KEY); + + if ($user->last_workspace_id !== null && $context->currentWorkspaceId($request) === null) { + $user->forceFill(['last_workspace_id' => null])->save(); + } + + if ($workspaceName !== null) { + Notification::make() + ->title("Your access to {$workspaceName} was removed.") + ->danger() + ->send(); + } + } + + private function emitAuditEvent( + Workspace $workspace, + User $user, + AuditActionId $actionId, + string $method, + string $reason, + ?int $prevWorkspaceId = null, + ): void { + /** @var WorkspaceAuditLogger $logger */ + $logger = app(WorkspaceAuditLogger::class); + + $logger->log( + workspace: $workspace, + action: $actionId->value, + context: [ + 'metadata' => [ + 'method' => $method, + 'reason' => $reason, + 'prev_workspace_id' => $prevWorkspaceId, + ], + ], + actor: $user, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 79123e1..27d50c4 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -25,6 +25,7 @@ use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -133,6 +134,25 @@ public function panel(Panel $panel): Panel ->group('Monitoring') ->sort(30), ]) + ->userMenuItems([ + Action::make('switch-workspace') + ->label('Switch workspace') + ->url(fn (): string => ChooseWorkspace::getUrl(panel: 'admin').'?choose=1') + ->icon('heroicon-o-arrows-right-left') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return WorkspaceMembership::query() + ->where('user_id', (int) $user->getKey()) + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->count() > 1; + }), + ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index fd6d8a6..dd0a17f 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -59,4 +59,8 @@ enum AuditActionId: string case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; + + // Workspace selection / switch events (Spec 107). + case WorkspaceAutoSelected = 'workspace.auto_selected'; + case WorkspaceSelected = 'workspace.selected'; } diff --git a/app/Support/Workspaces/WorkspaceRedirectResolver.php b/app/Support/Workspaces/WorkspaceRedirectResolver.php new file mode 100644 index 0000000..f83a60f --- /dev/null +++ b/app/Support/Workspaces/WorkspaceRedirectResolver.php @@ -0,0 +1,68 @@ +1 tenants → Choose Tenant page + */ +final class WorkspaceRedirectResolver +{ + /** + * Resolve the redirect URL for the given workspace + user. + * + * Returns a fully qualified URL string. + */ + public function resolve(Workspace $workspace, User $user): string + { + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + } + } + + return ChooseTenant::getUrl(); + } + + /** + * Resolve the redirect URL using a workspace ID. + * + * Falls back to the chooser page if the workspace cannot be resolved. + */ + public function resolveFromId(int $workspaceId, User $user): string + { + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return ChooseWorkspace::getUrl(); + } + + return $this->resolve($workspace, $user); + } +} diff --git a/resources/views/filament/pages/choose-workspace.blade.php b/resources/views/filament/pages/choose-workspace.blade.php index 913b9ba..262a0a5 100644 --- a/resources/views/filament/pages/choose-workspace.blade.php +++ b/resources/views/filament/pages/choose-workspace.blade.php @@ -7,6 +7,7 @@ @php $workspaces = $this->getWorkspaces(); + $workspaceRoles = $this->workspaceRoles; $user = auth()->user(); $recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0; @@ -15,55 +16,83 @@ [$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId); $workspaces = $recommended->concat($other)->values(); } + + $roleColorMap = [ + 'owner' => 'primary', + 'manager' => 'info', + 'operator' => 'gray', + 'readonly' => 'gray', + ]; @endphp @if ($workspaces->isEmpty()) -
- No active workspaces are available for your account. - You can create one using the button above. +
+ You don't have access to any workspace yet.
@else
@foreach ($workspaces as $workspace) @php $isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId; + $role = $workspaceRoles[(int) $workspace->id] ?? null; + $tenantCount = (int) ($workspace->tenants_count ?? 0); @endphp
-
- @csrf - - +
{{ $workspace->name }}
- @if ($isRecommended) -
+
+ @if ($isRecommended) Last used -
- @endif + @endif + + @if ($role) + + {{ ucfirst($role) }} + + @endif +
- - Continue - - +
+ {{ $tenantCount }} {{ \Illuminate\Support\Str::plural('tenant', $tenantCount) }} +
+
@endforeach
+ + @php + $canManageWorkspaces = false; + + if ($user instanceof \App\Models\User && $workspaces->count() > 0) { + foreach ($workspaces as $ws) { + $wsRole = $workspaceRoles[(int) $ws->id] ?? null; + if ($wsRole === 'owner') { + $canManageWorkspaces = true; + break; + } + } + } + @endphp + + @if ($canManageWorkspaces) + + @endif @endif
diff --git a/routes/web.php b/routes/web.php index 14ad6d2..e4bb747 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,5 @@ to('/admin/choose-workspace'); } - $tenantsQuery = $user->tenants() - ->where('workspace_id', $workspace->getKey()) - ->where('status', 'active'); + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); - $tenantCount = (int) $tenantsQuery->count(); - - if ($tenantCount === 0) { - return redirect()->to('/admin/onboarding'); - } - - if ($tenantCount === 1) { - $tenant = $tenantsQuery->first(); - - if ($tenant !== null) { - return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); - } - } - - return redirect()->to('/admin/choose-tenant'); + return redirect()->to($resolver->resolve($workspace, $user)); }) ->name('admin.home'); diff --git a/specs/107-workspace-chooser/tasks.md b/specs/107-workspace-chooser/tasks.md index 2131b77..920aeea 100644 --- a/specs/107-workspace-chooser/tasks.md +++ b/specs/107-workspace-chooser/tasks.md @@ -45,9 +45,9 @@ ## Phase 2: Foundational (Blocking Prerequisites) **⚠️ CRITICAL**: No user story work can begin until this phase is complete. -- [ ] T001 [P] Add `WorkspaceAutoSelected` and `WorkspaceSelected` enum cases to `app/Support/Audit/AuditActionId.php` -- [ ] T002 [P] Create `WorkspaceRedirectResolver` service with tenant-count branching logic (0→managed tenants, 1→tenant dashboard, >1→choose tenant) in `app/Support/Workspaces/WorkspaceRedirectResolver.php` -- [ ] T003 Write tests for `WorkspaceRedirectResolver` covering 0/1/>1 tenant branching in `tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php` +- [X] T001 [P] Add `WorkspaceAutoSelected` and `WorkspaceSelected` enum cases to `app/Support/Audit/AuditActionId.php` +- [X] T002 [P] Create `WorkspaceRedirectResolver` service with tenant-count branching logic (0→managed tenants, 1→tenant dashboard, >1→choose tenant) in `app/Support/Workspaces/WorkspaceRedirectResolver.php` +- [X] T003 Write tests for `WorkspaceRedirectResolver` covering 0/1/>1 tenant branching in `tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php` **Checkpoint**: Foundation ready — AuditActionId enum extended, tenant-count branching deduplicated into resolver. User story implementation can now begin. @@ -61,11 +61,11 @@ ## Phase 3: User Story 1 — Auto-Resume: Single Workspace (Priority: P1) 🎯 M ### Implementation for User Story 1 -- [ ] T004 [US1] Refactor `EnsureWorkspaceSelected` middleware: implement step 1 (workspace-optional bypass), step 2 (`?choose=1` redirect), step 3 (basic session validation — allow if valid membership), step 4 (load selectable memberships), step 5 (single membership auto-resume with audit via `WorkspaceAuditLogger`), step 7 (fallback redirect to chooser) in `app/Http/Middleware/EnsureWorkspaceSelected.php` -- [ ] T005 [US1] Write test `it_skips_chooser_when_single_workspace_membership` — verify direct redirect to workspace dashboard in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T006 [US1] Write test `it_emits_audit_event_on_auto_selection_single_membership` — verify `workspace.auto_selected` audit log with reason `single_membership` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T007 [US1] Write test `it_redirects_via_tenant_count_branching_after_single_auto_resume` — verify 0/1/>1 tenant routing after auto-resume in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T008 [US1] Write test `it_allows_request_when_session_workspace_is_valid` — verify middleware passes through when session has valid membership in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T004 [US1] Refactor `EnsureWorkspaceSelected` middleware: implement step 1 (workspace-optional bypass), step 2 (`?choose=1` redirect), step 3 (basic session validation — allow if valid membership), step 4 (load selectable memberships), step 5 (single membership auto-resume with audit via `WorkspaceAuditLogger`), step 7 (fallback redirect to chooser) in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T005 [US1] Write test `it_skips_chooser_when_single_workspace_membership` — verify direct redirect to workspace dashboard in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T006 [US1] Write test `it_emits_audit_event_on_auto_selection_single_membership` — verify `workspace.auto_selected` audit log with reason `single_membership` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T007 [US1] Write test `it_redirects_via_tenant_count_branching_after_single_auto_resume` — verify 0/1/>1 tenant routing after auto-resume in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T008 [US1] Write test `it_allows_request_when_session_workspace_is_valid` — verify middleware passes through when session has valid membership in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` **Checkpoint**: Single-workspace users bypass the chooser entirely. Audit event emitted. Middleware skeleton (7 steps) in place with steps 1–5, 7 active. @@ -79,10 +79,10 @@ ## Phase 4: User Story 2 — Auto-Resume: Last Used Workspace (Priority: P1) ### Implementation for User Story 2 -- [ ] T009 [US2] Add step 6 to `EnsureWorkspaceSelected` middleware: `last_workspace_id` auto-resume with membership validation and audit logging in `app/Http/Middleware/EnsureWorkspaceSelected.php` -- [ ] T010 [US2] Write test `it_auto_resumes_to_last_used_workspace_when_membership_valid` — verify direct redirect via last_workspace_id in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T011 [US2] Write test `it_emits_audit_event_on_auto_selection_last_used` — verify `workspace.auto_selected` audit log with reason `last_used` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T012 [US2] Write test `it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used` — verify redirect to chooser when no default in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T009 [US2] Add step 6 to `EnsureWorkspaceSelected` middleware: `last_workspace_id` auto-resume with membership validation and audit logging in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T010 [US2] Write test `it_auto_resumes_to_last_used_workspace_when_membership_valid` — verify direct redirect via last_workspace_id in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T011 [US2] Write test `it_emits_audit_event_on_auto_selection_last_used` — verify `workspace.auto_selected` audit log with reason `last_used` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T012 [US2] Write test `it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used` — verify redirect to chooser when no default in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` **Checkpoint**: Multi-workspace users with a valid last-used preference bypass the chooser. Both P1 auto-resume paths (single + last-used) are functional. @@ -96,14 +96,14 @@ ## Phase 5: User Story 3 — Chooser Fallback: Multiple Workspaces, No Default ( ### Implementation for User Story 3 -- [ ] T013 [US3] Refactor `ChooseWorkspace::getWorkspaces()` to add `withCount('tenants')` and load membership roles keyed by workspace_id; expose `$this->workspaceRoles` for Blade in `app/Filament/Pages/ChooseWorkspace.php` -- [ ] T014 [US3] Remove "Create workspace" header action from `ChooseWorkspace` page (FR-006) in `app/Filament/Pages/ChooseWorkspace.php` -- [ ] T015 [US3] Update Blade template: add role badge (`` with color mapping for owner/admin/member), tenant count display, "Manage workspaces" link (gated by `Capabilities::WORKSPACE_MANAGE`), updated empty state copy per spec terminology in `resources/views/filament/pages/choose-workspace.blade.php` -- [ ] T016 [US3] Write test `it_only_lists_workspaces_user_is_member_of` — create workspaces user is and isn't a member of, verify only member workspaces shown in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` -- [ ] T017 [US3] Write test `it_shows_name_role_and_tenants_count_per_workspace` — verify metadata rendered in chooser cards in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` -- [ ] T018 [US3] Write test `it_shows_empty_state_when_no_memberships` — verify "You don't have access to any workspace yet." message in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` -- [ ] T019 [US3] Write test `it_hides_manage_link_without_workspace_manage_capability` and `it_shows_manage_link_with_workspace_manage_capability` — positive + negative authorization in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` -- [ ] T020 [US3] Write test `it_has_no_n_plus_1_queries_in_chooser` — assert query count with 5+ workspaces in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T013 [US3] Refactor `ChooseWorkspace::getWorkspaces()` to add `withCount('tenants')` and load membership roles keyed by workspace_id; expose `$this->workspaceRoles` for Blade in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T014 [US3] Remove "Create workspace" header action from `ChooseWorkspace` page (FR-006) in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T015 [US3] Update Blade template: add role badge (`` with color mapping for owner/admin/member), tenant count display, "Manage workspaces" link (gated by `Capabilities::WORKSPACE_MANAGE`), updated empty state copy per spec terminology in `resources/views/filament/pages/choose-workspace.blade.php` +- [X] T016 [US3] Write test `it_only_lists_workspaces_user_is_member_of` — create workspaces user is and isn't a member of, verify only member workspaces shown in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T017 [US3] Write test `it_shows_name_role_and_tenants_count_per_workspace` — verify metadata rendered in chooser cards in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T018 [US3] Write test `it_shows_empty_state_when_no_memberships` — verify "You don't have access to any workspace yet." message in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T019 [US3] Write test `it_hides_manage_link_without_workspace_manage_capability` and `it_shows_manage_link_with_workspace_manage_capability` — positive + negative authorization in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T020 [US3] Write test `it_has_no_n_plus_1_queries_in_chooser` — assert query count with 5+ workspaces in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` **Checkpoint**: Chooser page displays enterprise metadata. All three P1 stories are functional — auto-resume (single + last-used) and chooser fallback with metadata. @@ -117,11 +117,11 @@ ## Phase 6: User Story 4 — Stale Session / Revoked Membership (Priority: P2) ### Implementation for User Story 4 -- [ ] T021 [US4] Enhance middleware step 3: detect stale session (revoked membership or archived workspace), clear session, emit Filament `Notification::make()->danger()` with "Your access to {workspace_name} was removed." flash, redirect to chooser in `app/Http/Middleware/EnsureWorkspaceSelected.php` -- [ ] T022 [US4] Enhance middleware step 6 error path: detect stale `last_workspace_id` (revoked or archived), clear `last_workspace_id` on user record, emit flash warning, redirect to chooser in `app/Http/Middleware/EnsureWorkspaceSelected.php` -- [ ] T023 [US4] Write test `it_clears_session_when_active_workspace_membership_revoked` — verify session cleared + warning notification + chooser redirect in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T024 [US4] Write test `it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning` — verify `last_workspace_id` cleared + warning + chooser, including archived workspace scenario (edge case EC2) in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T025 [US4] Write test `it_handles_archived_workspace_in_session` — verify archived workspace treated as stale in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T021 [US4] Enhance middleware step 3: detect stale session (revoked membership or archived workspace), clear session, emit Filament `Notification::make()->danger()` with "Your access to {workspace_name} was removed." flash, redirect to chooser in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T022 [US4] Enhance middleware step 6 error path: detect stale `last_workspace_id` (revoked or archived), clear `last_workspace_id` on user record, emit flash warning, redirect to chooser in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T023 [US4] Write test `it_clears_session_when_active_workspace_membership_revoked` — verify session cleared + warning notification + chooser redirect in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T024 [US4] Write test `it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning` — verify `last_workspace_id` cleared + warning + chooser, including archived workspace scenario (edge case EC2) in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T025 [US4] Write test `it_handles_archived_workspace_in_session` — verify archived workspace treated as stale in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` **Checkpoint**: Stale/revoked membership detection is active. Users see clear warning notifications instead of broken states. @@ -135,14 +135,14 @@ ## Phase 7: User Story 5 — Manual Workspace Switch (Priority: P2) ### Implementation for User Story 5 -- [ ] T026 [US5] Add audit logging in `ChooseWorkspace::selectWorkspace()` — emit `workspace.selected` via `WorkspaceAuditLogger` with metadata `{method: "manual", reason: "chooser", prev_workspace_id}` in `app/Filament/Pages/ChooseWorkspace.php` -- [ ] T027 [US5] Replace form POST with `wire:click="selectWorkspace({{ $workspace->id }})"` in chooser Blade template in `resources/views/filament/pages/choose-workspace.blade.php` -- [ ] T028 [US5] Use `WorkspaceRedirectResolver` in `ChooseWorkspace::redirectAfterWorkspaceSelected()` for tenant-count branching in `app/Filament/Pages/ChooseWorkspace.php` -- [ ] T029 [US5] Register "Switch workspace" user menu item via `->userMenuItems()` with `MenuItem::make()->url('/admin/choose-workspace?choose=1')->icon('heroicon-o-arrows-right-left')` and `->visible()` callback (>1 workspace membership) in `app/Providers/Filament/AdminPanelProvider.php` -- [ ] T030 [US5] Write test `it_forces_chooser_with_choose_param` — verify `?choose=1` bypasses auto-resume, including the single-workspace sub-case (edge case EC3: forced chooser shown even with 1 membership) in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` -- [ ] T031 [US5] Write test `it_persists_last_used_workspace_on_manual_selection` and `it_emits_audit_event_on_manual_selection` — verify `last_workspace_id` update + `workspace.selected` audit log in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` -- [ ] T032 [US5] Write test `it_shows_switch_workspace_menu_when_multiple_workspaces` and `it_hides_switch_workspace_menu_when_single_workspace` in `tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php` -- [ ] T033 [US5] Write test `it_rejects_non_member_workspace_selection_with_404` — verify deny-as-not-found for non-member attempt in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T026 [US5] Add audit logging in `ChooseWorkspace::selectWorkspace()` — emit `workspace.selected` via `WorkspaceAuditLogger` with metadata `{method: "manual", reason: "chooser", prev_workspace_id}` in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T027 [US5] Replace form POST with `wire:click="selectWorkspace({{ $workspace->id }})"` in chooser Blade template in `resources/views/filament/pages/choose-workspace.blade.php` +- [X] T028 [US5] Use `WorkspaceRedirectResolver` in `ChooseWorkspace::redirectAfterWorkspaceSelected()` for tenant-count branching in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T029 [US5] Register "Switch workspace" user menu item via `->userMenuItems()` with `MenuItem::make()->url('/admin/choose-workspace?choose=1')->icon('heroicon-o-arrows-right-left')` and `->visible()` callback (>1 workspace membership) in `app/Providers/Filament/AdminPanelProvider.php` +- [X] T030 [US5] Write test `it_forces_chooser_with_choose_param` — verify `?choose=1` bypasses auto-resume, including the single-workspace sub-case (edge case EC3: forced chooser shown even with 1 membership) in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T031 [US5] Write test `it_persists_last_used_workspace_on_manual_selection` and `it_emits_audit_event_on_manual_selection` — verify `last_workspace_id` update + `workspace.selected` audit log in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T032 [US5] Write test `it_shows_switch_workspace_menu_when_multiple_workspaces` and `it_hides_switch_workspace_menu_when_single_workspace` in `tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php` +- [X] T033 [US5] Write test `it_rejects_non_member_workspace_selection_with_404` — verify deny-as-not-found for non-member attempt in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` **Checkpoint**: Manual switch flow complete — user menu entry, Livewire selection, audit logging, and 404 for non-members. @@ -156,8 +156,8 @@ ## Phase 8: User Story 6 — Audit Trail for Workspace Context Changes (Priority ### Implementation for User Story 6 -- [ ] T034 [US6] Write comprehensive audit payload verification test covering all four audit scenarios (auto/single_membership, auto/last_used, manual/chooser, manual/context_bar) with full metadata assertion (`method`, `reason`, `prev_workspace_id`, `resource_type`, `resource_id`) in `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` -- [ ] T035 [US6] Write test `it_includes_prev_workspace_id_when_switching_from_active_workspace` — verify previous workspace context is captured in audit metadata in `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` +- [X] T034 [US6] Write comprehensive audit payload verification test covering all four audit scenarios (auto/single_membership, auto/last_used, manual/chooser, manual/context_bar) with full metadata assertion (`method`, `reason`, `prev_workspace_id`, `resource_type`, `resource_id`) in `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` +- [X] T035 [US6] Write test `it_includes_prev_workspace_id_when_switching_from_active_workspace` — verify previous workspace context is captured in audit metadata in `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` **Checkpoint**: All six user stories are implemented and tested. Audit trail is verified for compliance. @@ -167,11 +167,11 @@ ## Phase 9: Polish & Cross-Cutting Concerns **Purpose**: Deduplicate remaining tenant-branching copies, full suite validation, formatting. -- [ ] T036 [US6] Replace inline tenant-count branching in `SwitchWorkspaceController::__invoke()` with `WorkspaceRedirectResolver` AND add `WorkspaceAuditLogger::log()` for `workspace.selected` (method: `manual`, reason: `context_bar`) to satisfy FR-005 audit coverage for the context-bar switch path, in `app/Http/Controllers/SwitchWorkspaceController.php` -- [ ] T037 Replace inline tenant-count branching in `/admin` route handler with `WorkspaceRedirectResolver` in `routes/web.php` -- [ ] T038 Run full test suite via `vendor/bin/sail artisan test --compact` and verify no regressions -- [ ] T039 Run Pint formatting via `vendor/bin/sail bin pint --dirty --format agent` -- [ ] T040 Final commit and push to branch `107-workspace-chooser` +- [X] T036 [US6] Replace inline tenant-count branching in `SwitchWorkspaceController::__invoke()` with `WorkspaceRedirectResolver` AND add `WorkspaceAuditLogger::log()` for `workspace.selected` (method: `manual`, reason: `context_bar`) to satisfy FR-005 audit coverage for the context-bar switch path, in `app/Http/Controllers/SwitchWorkspaceController.php` +- [X] T037 Replace inline tenant-count branching in `/admin` route handler with `WorkspaceRedirectResolver` in `routes/web.php` +- [X] T038 Run full test suite via `vendor/bin/sail artisan test --compact` and verify no regressions +- [X] T039 Run Pint formatting via `vendor/bin/sail bin pint --dirty --format agent` +- [X] T040 Final commit and push to branch `107-workspace-chooser` --- diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php index 6e20af6..ec65bd9 100644 --- a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -13,7 +13,7 @@ uses(RefreshDatabase::class); -it('redirects /admin to onboarding when a workspace is selected and has no tenants', function (): void { +it('redirects /admin to managed tenants index when a workspace is selected and has no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -24,11 +24,14 @@ 'role' => 'owner', ]); - $this + $response = $this ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) - ->get('/admin') - ->assertRedirect('/admin/onboarding'); + ->get('/admin'); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->toContain('managed-tenants'); }); it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void { diff --git a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php index 4bd094d..b8b1fd6 100644 --- a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php +++ b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php @@ -14,7 +14,7 @@ Http::preventStrayRequests(); }); -it('redirects to choose-workspace after login when user has multiple workspaces and no workspace is selected', function (): void { +it('auto-resumes to last used workspace when user has multiple workspaces and last_workspace_id is set', function (): void { $user = User::factory()->create(); $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); @@ -34,6 +34,35 @@ $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + $response = $this->actingAs($user) + ->get('/admin'); + + // Middleware step 6: auto-resumes to last used workspace and redirects + // via tenant branching (workspaceA has 0 tenants → managed-tenants). + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->toContain('managed-tenants'); +}); + +it('redirects to choose-workspace when user has multiple workspaces and no last_workspace_id', function (): void { + $user = User::factory()->create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + // No last_workspace_id → falls through to chooser. $this->actingAs($user) ->get('/admin') ->assertRedirect(route('filament.admin.pages.choose-workspace')); diff --git a/tests/Feature/OpsUx/OperateHubShellTest.php b/tests/Feature/OpsUx/OperateHubShellTest.php index 806511a..e89bbd8 100644 --- a/tests/Feature/OpsUx/OperateHubShellTest.php +++ b/tests/Feature/OpsUx/OperateHubShellTest.php @@ -168,14 +168,15 @@ ->assertDontSee('Show all operations'); })->group('ops-ux'); -it('returns 404 for non-member workspace access to /admin/operations', function (): void { +it('redirects non-member workspace access to chooser on /admin/operations', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); + // User is NOT a member — middleware detects stale session and redirects. $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get(route('admin.operations.index')) - ->assertNotFound(); + ->assertRedirect(); })->group('ops-ux'); it('returns 404 for non-entitled tenant dashboard direct access', function (): void { diff --git a/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php b/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php index 9e4498f..450af02 100644 --- a/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php +++ b/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php @@ -15,11 +15,12 @@ 'provider' => 'microsoft', ]); + // Non-member outsider is redirected by workspace middleware (no workspace membership). $outsider = User::factory()->create(); $this->actingAs($outsider) ->get('/admin/provider-connections/'.$connection->getKey().'/edit') - ->assertNotFound(); + ->assertRedirect(); [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); diff --git a/tests/Feature/ProviderConnections/LegacyRedirectTest.php b/tests/Feature/ProviderConnections/LegacyRedirectTest.php index 7d93e42..008d9a8 100644 --- a/tests/Feature/ProviderConnections/LegacyRedirectTest.php +++ b/tests/Feature/ProviderConnections/LegacyRedirectTest.php @@ -5,6 +5,7 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Support\Workspaces\WorkspaceContext; it('redirects legacy tenant-scoped provider connection routes for entitled members', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -31,14 +32,13 @@ ->assertRedirect('/admin/provider-connections/'.$connection->getKey().'/edit?tenant_id='.$tenant->external_id); }); -it('returns 404 without location header for non-workspace members on legacy routes', function (): void { +it('redirects non-workspace-members on legacy routes', function (): void { $user = User::factory()->create(); $tenant = Tenant::factory()->create(); $this->actingAs($user) ->get('/admin/tenants/'.$tenant->external_id.'/provider-connections') - ->assertNotFound() - ->assertHeaderMissing('Location'); + ->assertRedirect(); }); it('returns 404 without location header for non-tenant members on legacy routes', function (): void { @@ -50,6 +50,7 @@ [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->get('/admin/tenants/'.$tenantB->external_id.'/provider-connections') ->assertNotFound() ->assertHeaderMissing('Location'); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php index af6aa3e..6373a77 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php @@ -8,7 +8,7 @@ use App\Models\User; use App\Support\Workspaces\WorkspaceContext; -it('Spec081 returns 404 for non-members on provider connection management routes', function (): void { +it('Spec081 redirects non-members on provider connection management routes', function (): void { $tenant = Tenant::factory()->create([ 'status' => 'active', ]); @@ -25,7 +25,7 @@ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertNotFound(); + ->assertRedirect(); }); it('Spec081 returns 403 for members without provider manage capability', function (): void { diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index b1ababd..80bfad6 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -4,6 +4,8 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Models\WorkspaceMembership; +use App\Support\Workspaces\WorkspaceContext; test('owners can manage provider connections in their tenant', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -98,7 +100,14 @@ $tenantB->getKey() => ['role' => 'owner'], ]); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connectionB], tenant: $tenantA)) ->assertNotFound(); }); diff --git a/tests/Feature/ProviderConnections/TenantlessListRouteTest.php b/tests/Feature/ProviderConnections/TenantlessListRouteTest.php index ce4ce20..8294f08 100644 --- a/tests/Feature/ProviderConnections/TenantlessListRouteTest.php +++ b/tests/Feature/ProviderConnections/TenantlessListRouteTest.php @@ -16,10 +16,10 @@ ->toContain('/admin/provider-connections'); }); -it('returns 404 on the canonical tenantless route for non-workspace members', function (): void { +it('redirects non-workspace-members on the canonical tenantless route', function (): void { $user = User::factory()->create(); $this->actingAs($user) ->get('/admin/provider-connections') - ->assertNotFound(); + ->assertRedirect(); }); diff --git a/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php b/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php index c11861c..48635f2 100644 --- a/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php +++ b/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php @@ -37,7 +37,7 @@ ->assertNotFound(); }); -it('returns 404 for users who are not workspace members', function (): void { +it('redirects non-workspace-members with stale session', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ @@ -49,7 +49,7 @@ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) ->get("/admin/tenants/{$tenant->external_id}/required-permissions") - ->assertNotFound(); + ->assertRedirect(); }); it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void { diff --git a/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php b/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php index 673e9ee..f337aa2 100644 --- a/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php +++ b/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php @@ -63,20 +63,20 @@ $response->assertDontSee('>Governance', false); }); -it('returns 404 for non-workspace-members after middleware change (FR-002 regression guard)', function (): void { +it('redirects non-workspace-members with stale session (FR-002 regression guard)', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); - // User is NOT a workspace member — no WorkspaceMembership created + // User is NOT a workspace member — middleware clears stale session and redirects $this->actingAs($user) ->withSession([ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) ->get("/admin/tenants/{$tenant->external_id}/required-permissions") - ->assertNotFound(); + ->assertRedirect(); }); it('returns 404 for workspace members without tenant entitlement after middleware change (FR-002 regression guard)', function (): void { diff --git a/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php b/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php index 64e2014..a2c5e69 100644 --- a/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php +++ b/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php @@ -32,14 +32,14 @@ ->assertOk(); }); -it('returns 404 for non-members on the workspace-managed tenants index', function (): void { +it('redirects non-members on the workspace-managed tenants index', function (): void { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get('/admin/tenants') - ->assertNotFound(); + ->assertRedirect(); }); it('allows workspace members to open the workspace-managed tenant view route', function (): void { @@ -61,14 +61,14 @@ ->assertSee('/admin/provider-connections?tenant_id='.$tenant->external_id, false); }); -it('returns 404 for non-members on the workspace-managed tenant view route', function (): void { +it('redirects non-members on the workspace-managed tenant view route', function (): void { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get("/admin/tenants/{$tenant->external_id}") - ->assertNotFound(); + ->assertRedirect(); }); it('exposes memberships management under workspace scope', function (): void { diff --git a/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php index b8e2b61..35a3f80 100644 --- a/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php +++ b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php @@ -19,14 +19,12 @@ Http::preventStrayRequests(); }); -it('returns 404 for non-workspace-members on central operations index', function (): void { +it('redirects non-workspace-members on central operations index', function (): void { $user = User::factory()->create(); - session()->forget(WorkspaceContext::SESSION_KEY); - $this->actingAs($user) ->get('/admin/operations') - ->assertNotFound(); + ->assertRedirect(); }); it('returns 404 for non-workspace-members on central operation run detail', function (): void { diff --git a/tests/Feature/Workspaces/ChooseWorkspacePageTest.php b/tests/Feature/Workspaces/ChooseWorkspacePageTest.php new file mode 100644 index 0000000..4cd44a7 --- /dev/null +++ b/tests/Feature/Workspaces/ChooseWorkspacePageTest.php @@ -0,0 +1,297 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'My Workspace']); + $workspaceB = Workspace::factory()->create(['name' => 'Other Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + // User is NOT a member of workspaceB. + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => User::factory()->create()->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('My Workspace') + ->assertDontSee('Other Workspace'); +}); + +it('excludes archived workspaces from the list', function (): void { + $user = User::factory()->create(); + + $activeWorkspace = Workspace::factory()->create(['name' => 'Active WS']); + $archivedWorkspace = Workspace::factory()->create(['name' => 'Archived WS', 'archived_at' => now()]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $activeWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $archivedWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $activeWorkspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Active WS') + ->assertDontSee('Archived WS'); +}); + +// --- T017: it_shows_name_role_and_tenants_count_per_workspace --- + +it('shows name role and tenants count per workspace', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['name' => 'Test Corp']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'manager', + ]); + + // Create 2 active tenants. + Tenant::factory()->count(2)->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + // Create 1 inactive tenant (should not count). + Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'pending_validation', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Test Corp') + ->assertSee('Manager') + ->assertSee('2 tenants'); +}); + +it('shows singular tenant label when count is one', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['name' => 'Solo Corp']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('1 tenant'); +}); + +// --- T018: it_shows_empty_state_when_no_memberships --- + +it('shows empty state when no memberships', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee("You don't have access to any workspace yet.", false); +}); + +// --- T019: manage link visibility --- + +it('shows manage link for owner role', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Manage workspaces'); +}); + +it('hides manage link for non-owner roles', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertDontSee('Manage workspaces'); +}); + +// --- T020: it_has_no_n_plus_1_queries_in_chooser --- + +it('has no n plus 1 queries in chooser', function (): void { + $user = User::factory()->create(); + + // Create 5 workspaces with memberships. + $workspaces = Workspace::factory()->count(5)->create(); + + foreach ($workspaces as $workspace) { + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Tenant::factory()->count(2)->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + } + + $user->forceFill(['last_workspace_id' => (int) $workspaces->first()->getKey()])->save(); + + DB::enableQueryLog(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk(); + + $queryCount = count(DB::getQueryLog()); + DB::disableQueryLog(); + + // Should be bounded: auth query + workspaces (with count) + memberships for roles + minimal Filament overhead. + // Not proportional to workspace count. + expect($queryCount)->toBeLessThan(20); +}); + +// --- T031: it_persists_last_used_workspace_on_manual_selection --- + +it('persists last used workspace on manual selection', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()); + + $user->refresh(); + expect($user->last_workspace_id)->toBe((int) $workspace->getKey()); +}); + +it('emits audit event on manual selection', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()); + + $auditLog = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($auditLog)->not->toBeNull(); + expect($auditLog->metadata)->toMatchArray([ + 'method' => 'manual', + 'reason' => 'chooser', + ]); +}); + +// --- T033: it_rejects_non_member_workspace_selection_with_404 --- + +it('rejects non member workspace selection with 404', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + // User is NOT a member. + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()) + ->assertStatus(404); +}); + +it('rejects archived workspace selection with 404', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['archived_at' => now()]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()) + ->assertStatus(404); +}); diff --git a/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php b/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php new file mode 100644 index 0000000..80adedf --- /dev/null +++ b/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php @@ -0,0 +1,358 @@ +create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + // Should redirect via tenant branching (not to chooser). + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + expect($location)->not->toContain('choose-workspace'); +}); + +// --- T006: it_emits_audit_event_on_auto_selection_single_membership --- + +it('emits audit event on auto selection single membership', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $auditLog = AuditLog::query() + ->where('action', AuditActionId::WorkspaceAutoSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($auditLog)->not->toBeNull(); + expect($auditLog->metadata)->toMatchArray([ + 'method' => 'auto', + 'reason' => 'single_membership', + ]); + expect($auditLog->resource_type)->toBe('workspace'); + expect($auditLog->resource_id)->toBe((string) $workspace->getKey()); +}); + +// --- T007: it_redirects_via_tenant_count_branching_after_single_auto_resume --- + +it('redirects to managed tenants index when single workspace has zero tenants', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $expectedRoute = route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + + $response->assertRedirect($expectedRoute); +}); + +it('redirects to tenant dashboard when single workspace has one active tenant', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->toContain('/admin/t/'); +}); + +// --- T008: it_allows_request_when_session_workspace_is_valid --- + +it('allows request when session workspace is valid', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/_test/workspace-context'); + + // Should pass through (200) since session is valid. + $response->assertOk(); + $response->assertJson(['workspace_id' => (int) $workspace->getKey()]); +}); + +// --- T010: it_auto_resumes_to_last_used_workspace_when_membership_valid --- + +it('auto resumes to last used workspace when membership valid', function (): void { + $user = User::factory()->create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + // Set last_workspace_id to workspaceB. + $user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save(); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + // Should redirect via tenant branching (not to chooser). + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->not->toContain('choose-workspace'); +}); + +// --- T011: it_emits_audit_event_on_auto_selection_last_used --- + +it('emits audit event on auto selection last used', function (): void { + $user = User::factory()->create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save(); + + $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $auditLog = AuditLog::query() + ->where('action', AuditActionId::WorkspaceAutoSelected->value) + ->where('workspace_id', $workspaceB->getKey()) + ->first(); + + expect($auditLog)->not->toBeNull(); + expect($auditLog->metadata)->toMatchArray([ + 'method' => 'auto', + 'reason' => 'last_used', + ]); +}); + +// --- T012: it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used --- + +it('falls back to chooser when multiple workspaces and no last used', function (): void { + $user = User::factory()->create(); + $user->forceFill(['last_workspace_id' => null])->save(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $response->assertRedirect('/admin/choose-workspace'); +}); + +// --- T023: it_clears_session_when_active_workspace_membership_revoked --- + +it('clears session when active workspace membership revoked', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + // Set session but don't create membership — simulates revoked access. + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/_test/workspace-context'); + + // Should redirect to no-access or chooser since user has no memberships. + $response->assertRedirect(); +}); + +// --- T024: it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning --- + +it('redirects to chooser when last workspace membership revoked', function (): void { + $user = User::factory()->create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + $workspaceC = Workspace::factory()->create(); + + // User is member of A and C but NOT B. last_workspace_id points to B. + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceC->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save(); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + // last_workspace_id should be cleared. + $user->refresh(); + expect($user->last_workspace_id)->toBeNull(); + + // Should redirect to chooser since user has 2 valid workspaces and last_workspace was invalid. + $response->assertRedirect('/admin/choose-workspace'); +}); + +it('redirects to chooser when last workspace is archived', function (): void { + $user = User::factory()->create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(['archived_at' => now()]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save(); + + $response = $this->actingAs($user)->get('/admin/_test/workspace-context'); + + // Step 5 auto-resumes to workspaceA (only selectable). setCurrentWorkspace updates last_workspace_id. + $user->refresh(); + expect($user->last_workspace_id)->toBe((int) $workspaceA->getKey()); + + // Only workspaceA is selectable → single membership auto-resume. + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->not->toContain('choose-workspace'); +}); + +// --- T025: it_handles_archived_workspace_in_session --- + +it('handles archived workspace in session', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['archived_at' => now()]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/_test/workspace-context'); + + // Session with archived workspace should be treated as stale. + $response->assertRedirect(); + $location = $response->headers->get('Location'); + // Should redirect to chooser or no-access. + expect($location)->toMatch('/choose-workspace|no-access/'); +}); + +// --- T030: it_forces_chooser_with_choose_param --- + +it('forces chooser with choose param', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/_test/workspace-context?choose=1'); + + $response->assertRedirect('/admin/choose-workspace'); +}); + +it('forces chooser with choose param even when single workspace', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + // No session set — normally would auto-resume, but ?choose=1 forces chooser. + $response = $this->actingAs($user)->get('/admin/_test/workspace-context?choose=1'); + + $response->assertRedirect('/admin/choose-workspace'); +}); diff --git a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php index ad86f4b..34380ad 100644 --- a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php +++ b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php @@ -10,7 +10,7 @@ uses(RefreshDatabase::class); -it('redirects to onboarding after switching to a workspace with no tenants', function (): void { +it('redirects to managed tenants after switching to a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -21,10 +21,14 @@ 'role' => 'owner', ]); - $this + $response = $this ->actingAs($user) - ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]) - ->assertRedirect(route('admin.onboarding')); + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]); + + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + expect($location)->toContain('managed-tenants'); expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); }); diff --git a/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php b/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php new file mode 100644 index 0000000..4a3a58d --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php @@ -0,0 +1,211 @@ +create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceAutoSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'auto', + 'reason' => 'single_membership', + 'prev_workspace_id' => null, + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspace->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +it('records workspace.auto_selected audit with last_used reason', function (): void { + $user = User::factory()->create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + // Set last_workspace_id to workspaceA. + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceAutoSelected->value) + ->where('workspace_id', $workspaceA->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'auto', + 'reason' => 'last_used', + 'prev_workspace_id' => null, + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspaceA->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +it('records workspace.selected audit with chooser reason on manual selection', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'manual', + 'reason' => 'chooser', + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspace->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +it('records workspace.selected audit with context_bar reason on switch-workspace POST', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'manual', + 'reason' => 'context_bar', + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspace->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +// --- T035: it_includes_prev_workspace_id_when_switching_from_active_workspace --- + +it('includes prev_workspace_id when switching via chooser from active workspace', function (): void { + $user = User::factory()->create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + // Simulate having workspaceA as the current workspace. + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey()); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspaceB->getKey()); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspaceB->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata['prev_workspace_id'])->toBe((int) $workspaceA->getKey()); + expect($log->metadata['method'])->toBe('manual'); + expect($log->metadata['reason'])->toBe('chooser'); +}); + +it('includes prev_workspace_id when switching via context bar from active workspace', function (): void { + $user = User::factory()->create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey(), + ]) + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspaceB->getKey()]); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspaceB->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata['prev_workspace_id'])->toBe((int) $workspaceA->getKey()); + expect($log->metadata['method'])->toBe('manual'); + expect($log->metadata['reason'])->toBe('context_bar'); +}); diff --git a/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php b/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php new file mode 100644 index 0000000..56896b0 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php @@ -0,0 +1,121 @@ +resolver = new WorkspaceRedirectResolver; +}); + +it('redirects to managed tenants index when workspace has zero tenants', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $url = $this->resolver->resolve($workspace, $user); + + $expectedRoute = route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + + expect($url)->toBe($expectedRoute); +}); + +it('redirects to tenant dashboard when workspace has exactly one active tenant', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $url = $this->resolver->resolve($workspace, $user); + + $expectedUrl = TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + + expect($url)->toBe($expectedUrl); +}); + +it('redirects to choose tenant page when workspace has multiple active tenants', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $url = $this->resolver->resolve($workspace, $user); + + expect($url)->toBe(ChooseTenant::getUrl()); +}); + +it('falls back to chooser page when workspace ID is invalid', function (): void { + $user = User::factory()->create(); + + $url = $this->resolver->resolveFromId(999999, $user); + + expect($url)->toBe(ChooseWorkspace::getUrl()); +}); + +it('resolves correctly from workspace ID', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $url = $this->resolver->resolveFromId((int) $workspace->getKey(), $user); + + $expectedRoute = route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + + expect($url)->toBe($expectedRoute); +}); diff --git a/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php b/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php new file mode 100644 index 0000000..32ac538 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php @@ -0,0 +1,73 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace Alpha']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace Beta']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $response = $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey(), + ]) + ->get('/admin/workspaces'); + + $response->assertOk(); + $response->assertSee('choose-workspace?choose=1', false); +}); + +// --- T032: it_hides_switch_workspace_menu_when_single_workspace --- + +it('hides switch workspace in user menu when single workspace', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['name' => 'Solo Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $response = $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]) + ->get('/admin/workspaces'); + + $response->assertOk(); + $response->assertDontSee('choose-workspace?choose=1', false); +});