feat(107): implement Workspace Chooser v1 — all 40 tasks complete
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)
This commit is contained in:
parent
169a6a1198
commit
051db1842d
@ -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<Action>
|
||||
* Workspace roles keyed by workspace_id.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
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 App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||
use Closure;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceSelected
|
||||
@ -19,10 +22,20 @@ class EnsureWorkspaceSelected
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Spec 107 — 7-step algorithm:
|
||||
* 1. If workspace-optional path → allow
|
||||
* 2. If ?choose=1 → redirect to chooser
|
||||
* 3. If session set → validate membership; stale → clear + warn + chooser
|
||||
* 4. Load selectable memberships
|
||||
* 5. If exactly 1 → auto-select + audit + redirect via tenant branching
|
||||
* 6. If last_workspace_id valid → auto-select + audit + redirect
|
||||
* 7. Else → redirect to chooser
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Auth-related routes are always allowed.
|
||||
$routeName = $request->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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
68
app/Support/Workspaces/WorkspaceRedirectResolver.php
Normal file
68
app/Support/Workspaces/WorkspaceRedirectResolver.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Resolves the redirect URL after a workspace is set.
|
||||
*
|
||||
* Tenant-count branching (FR-009):
|
||||
* - 0 tenants → Managed Tenants index
|
||||
* - 1 tenant → Tenant Dashboard directly
|
||||
* - >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);
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
No active workspaces are available for your account.
|
||||
You can create one using the button above.
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
You don't have access to any workspace yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@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
|
||||
|
||||
<div
|
||||
wire:key="workspace-{{ $workspace->id }}"
|
||||
x-data
|
||||
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
|
||||
class="cursor-pointer rounded-lg border p-4 dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200' }}"
|
||||
wire:click="selectWorkspace({{ (int) $workspace->id }})"
|
||||
class="cursor-pointer rounded-lg border p-4 transition hover:shadow-sm dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200 hover:border-gray-300 dark:hover:border-gray-700' }}"
|
||||
>
|
||||
<form x-ref="form" method="POST" action="{{ route('admin.switch-workspace') }}" class="flex flex-col gap-3">
|
||||
@csrf
|
||||
<input type="hidden" name="workspace_id" value="{{ (int) $workspace->id }}" />
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $workspace->name }}
|
||||
</div>
|
||||
|
||||
@if ($isRecommended)
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($isRecommended)
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Last used
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($role)
|
||||
<x-filament::badge :color="$roleColorMap[$role] ?? 'gray'" size="sm">
|
||||
{{ ucfirst($role) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="w-full"
|
||||
>
|
||||
Continue
|
||||
</x-filament::button>
|
||||
</form>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $tenantCount }} {{ \Illuminate\Support\Str::plural('tenant', $tenantCount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@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)
|
||||
<div class="mt-2 text-center">
|
||||
<a href="/admin/workspaces" class="text-sm text-primary-600 hover:underline dark:text-primary-400">
|
||||
Manage workspaces
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Http\Controllers\AdminConsentCallbackController;
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\ClearTenantContextController;
|
||||
@ -13,6 +12,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -60,25 +60,10 @@
|
||||
return redirect()->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');
|
||||
|
||||
|
||||
@ -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 (`<x-filament::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 (`<x-filament::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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -63,20 +63,20 @@
|
||||
$response->assertDontSee('>Governance</span>', 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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
297
tests/Feature/Workspaces/ChooseWorkspacePageTest.php
Normal file
297
tests/Feature/Workspaces/ChooseWorkspacePageTest.php
Normal file
@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
// --- T016: it_only_lists_workspaces_user_is_member_of ---
|
||||
|
||||
it('only lists workspaces user is member of', function (): void {
|
||||
$user = User::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// --- T005: it_skips_chooser_when_single_workspace_membership ---
|
||||
|
||||
it('skips chooser when single workspace membership', 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');
|
||||
|
||||
// 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');
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
|
||||
211
tests/Feature/Workspaces/WorkspaceAuditTrailTest.php
Normal file
211
tests/Feature/Workspaces/WorkspaceAuditTrailTest.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// --- T034: Comprehensive audit payload verification across all four scenarios ---
|
||||
|
||||
it('records workspace.auto_selected audit with single_membership reason', 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');
|
||||
|
||||
$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');
|
||||
});
|
||||
121
tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php
Normal file
121
tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->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);
|
||||
});
|
||||
73
tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php
Normal file
73
tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
// --- T032: it_shows_switch_workspace_menu_when_multiple_workspaces ---
|
||||
|
||||
it('shows switch workspace in user menu when multiple workspaces', function (): void {
|
||||
$user = User::factory()->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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user