TenantAtlas/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php
ahmido e15eee8f26 fix: consolidate tenant creation + harden selection flows (#131)
## Summary
- Removes the legacy Tenant CRUD create page (`/admin/tenants/create`) so tenant creation is handled exclusively via the onboarding wizard.
- Updates tenant selection flows and pages to prevent Livewire polling/notification-related 404s on workspace-scoped routes.
- Aligns empty-state UX with enterprise patterns (avoid duplicate CTAs).

## Key changes
- Tenant creation
  - Removed `CreateTenant` page + route from `TenantResource`.
  - `TenantResource::canCreate()` now returns `false` (CRUD creation disabled).
  - Tenants list now surfaces an **Add tenant** action that links to onboarding (`admin.onboarding`).
- Onboarding wizard
  - Removed redundant legacy step-cards from the blade view (Wizard schema is the source of truth).
  - Disabled topbar on the onboarding page to avoid lazy-loaded notifications.
- Choose tenant
  - Enterprise UI redesign + workspace context.
  - Uses Livewire `selectTenant()` instead of a form POST.
  - Disabled topbar and gated BODY_END hook to avoid background polling.
- Baseline profiles
  - Hide header create action when table is empty to avoid duplicate CTAs.

## Tests
- `vendor/bin/sail artisan test --compact --filter='Onboarding|ManagedTenantOnboarding'`
- `vendor/bin/sail artisan test --compact --filter='ManagedTenantsLivewireUpdate'`
- `vendor/bin/sail artisan test --compact --filter='TenantSetup|TenantResourceAuth|TenantAdminAuth|ListTenants'`
- `vendor/bin/sail artisan test --compact --filter='BaselineProfile'`
- `vendor/bin/sail artisan test --compact --filter='ChooseTenant|TenantMake|TenantScoping|AdminTenantScoped|AdminHomeRedirect|WorkspaceContext'`

## Notes
- Filament v5 / Livewire v4 compatible.
- No new assets introduced; no deploy pipeline changes required.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #131
2026-02-22 19:54:24 +00:00

359 lines
12 KiB
PHP

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