From b6343d5c3af069e6ea75f4f9bc6660f92dd9a26c Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 3 Feb 2026 17:30:15 +0000 Subject: [PATCH] feat: unified managed tenant onboarding wizard (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073). ## Summary ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/-/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/88 --- .github/agents/copilot-instructions.md | 4 +- app/Filament/Pages/ChooseTenant.php | 6 - .../ManagedTenantOnboardingWizard.php | 1244 +++++++++++++++++ .../Workspaces/ManagedTenantsLanding.php | 6 - .../Workspaces/WorkspaceResource.php | 2 + .../Controllers/SwitchWorkspaceController.php | 2 +- app/Models/TenantOnboardingSession.php | 54 + app/Providers/AuthServiceProvider.php | 27 +- app/Providers/Filament/AdminPanelProvider.php | 13 +- app/Services/Audit/WorkspaceAuditLogger.php | 7 +- .../Auth/WorkspaceRoleCapabilityMap.php | 2 + app/Services/Intune/AuditLogger.php | 7 +- app/Support/Audit/AuditActionId.php | 5 + app/Support/Audit/AuditContextSanitizer.php | 66 + app/Support/Auth/Capabilities.php | 3 + .../Badges/Domains/TenantStatusBadge.php | 1 + ...reate_tenant_onboarding_sessions_table.php | 43 + ...90522_enforce_tenant_workspace_binding.php | 128 ++ ...naged_tenant_onboarding_sessions_table.php | 40 + .../filament/pages/choose-tenant.blade.php | 17 +- ...managed-tenant-onboarding-wizard.blade.php | 170 +++ .../managed-tenants-landing.blade.php | 18 +- routes/web.php | 73 +- .../checklists/requirements.md | 36 + .../contracts/http.openapi.yaml | 50 + .../contracts/onboarding-actions.md | 57 + .../data-model.md | 84 ++ .../plan.md | 163 +++ .../quickstart.md | 35 + .../research.md | 62 + .../spec.md | 185 +++ .../tasks.md | 159 +++ tests/Feature/AdminNewRedirectTest.php | 2 +- .../WorkspaceAuditLoggerRedactionTest.php | 46 + .../DbOnlyPagesDoNotMakeHttpRequestsTest.php | 16 +- .../Auth/SessionSeparationSmokeTest.php | 35 +- .../BackupScheduleCrudTest.php | 8 +- .../Feature/Badges/TenantStatusBadgeTest.php | 18 + .../DirectoryGroups/BrowseGroupsTest.php | 9 +- .../NoLiveGraphOnRenderTest.php | 4 +- ...oChooseTenantWhenWorkspaceSelectedTest.php | 2 +- ...AppProtectionPolicySettingsDisplayTest.php | 25 +- ...tyStateRegisterTenantCtaVisibilityTest.php | 6 +- ...EnrollmentAutopilotSettingsDisplayTest.php | 46 +- .../EntraGroupSyncRunResourceTest.php | 17 +- .../GroupPolicyConfigurationHydrationTest.php | 15 +- tests/Feature/Filament/HousekeepingTest.php | 70 +- .../Filament/InventoryItemResourceTest.php | 9 +- tests/Feature/Filament/InventoryPagesTest.php | 7 +- .../Filament/InventorySyncRunResourceTest.php | 9 +- .../Filament/MalformedSnapshotWarningTest.php | 12 +- .../Filament/ODataTypeMismatchTest.php | 12 +- tests/Feature/Filament/PolicyListingTest.php | 2 +- .../Filament/PolicySettingsDisplayTest.php | 13 +- ...olicySettingsStandardRendersArraysTest.php | 13 +- .../PolicyVersionReadableLayoutTest.php | 13 +- .../PolicyVersionScopeTagsDisplayTest.php | 24 +- .../Filament/PolicyVersionSettingsTest.php | 46 +- tests/Feature/Filament/PolicyVersionTest.php | 12 +- .../PolicyViewSettingsCatalogReadableTest.php | 85 +- .../ScriptPoliciesNormalizedDisplayTest.php | 16 +- .../SettingsCatalogPolicyHydrationTest.php | 11 +- ...ingsCatalogPolicyNormalizedDisplayTest.php | 6 +- .../SettingsCatalogPolicySyncTest.php | 6 +- ...gsCatalogRestoreApplySettingsPatchTest.php | 6 +- .../Filament/SettingsCatalogRestoreTest.php | 5 +- ...SettingsCatalogSettingsTableRenderTest.php | 6 +- .../Filament/TenantDashboardDbOnlyTest.php | 2 +- .../TenantPortfolioContextSwitchTest.php | 54 +- .../Feature/Filament/TenantRbacWizardTest.php | 41 +- tests/Feature/Filament/TenantSetupTest.php | 30 +- .../Filament/WindowsUpdateRingPolicyTest.php | 6 +- ...aceContextTopbarAndTenantSelectionTest.php | 12 +- .../ManagedTenantOnboardingWizardTest.php | 874 ++++++++++++ .../ManagedTenants/OnboardingRedirectTest.php | 4 +- tests/Feature/MonitoringOperationsTest.php | 12 +- .../Feature/OpsUx/FailureSanitizationTest.php | 4 +- .../PolicyVersionViewAssignmentsTest.php | 4 +- .../RunAuthorizationTenantIsolationTest.php | 16 +- .../ManagedTenantsWorkspaceRoutingTest.php | 4 +- ...sToTenantRegistrationWhenNoTenantsTest.php | 2 +- tests/Pest.php | 23 +- tests/Unit/TenantCurrentTest.php | 2 +- 83 files changed, 3894 insertions(+), 597 deletions(-) create mode 100644 app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php create mode 100644 app/Models/TenantOnboardingSession.php create mode 100644 app/Support/Audit/AuditContextSanitizer.php create mode 100644 database/migrations/2026_02_03_090449_create_tenant_onboarding_sessions_table.php create mode 100644 database/migrations/2026_02_03_090522_enforce_tenant_workspace_binding.php create mode 100644 database/migrations/2026_02_03_150001_create_managed_tenant_onboarding_sessions_table.php create mode 100644 resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/checklists/requirements.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/contracts/http.openapi.yaml create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/contracts/onboarding-actions.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/data-model.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/plan.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/quickstart.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/research.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/spec.md create mode 100644 specs/073-unified-managed-tenant-onboarding-wizard/tasks.md create mode 100644 tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php create mode 100644 tests/Feature/Badges/TenantStatusBadgeTest.php create mode 100644 tests/Feature/ManagedTenantOnboardingWizardTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 176f98f..b1b09cc 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -14,6 +14,8 @@ ## Active Technologies - PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish) - PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting) - PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting) +- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard) +- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard) - PHP 8.4.15 (feat/005-bulk-operations) @@ -33,9 +35,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x - 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 - 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 -- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] diff --git a/app/Filament/Pages/ChooseTenant.php b/app/Filament/Pages/ChooseTenant.php index bead16d..31e8181 100644 --- a/app/Filament/Pages/ChooseTenant.php +++ b/app/Filament/Pages/ChooseTenant.php @@ -4,7 +4,6 @@ namespace App\Filament\Pages; -use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; @@ -73,11 +72,6 @@ public function selectTenant(int $tenantId): void $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } - public function canRegisterTenant(): bool - { - return RegisterTenantPage::canView(); - } - private function persistLastTenant(User $user, Tenant $tenant): void { if (Schema::hasColumn('users', 'last_tenant_id')) { diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php new file mode 100644 index 0000000..a8b9b7e --- /dev/null +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -0,0 +1,1244 @@ + + */ + public array $data = []; + + /** + * @var array + */ + public array $selectedBootstrapOperationTypes = []; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return []; + } + + public function mount(Workspace $workspace): void + { + $this->workspace = $workspace; + + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! app(WorkspaceContext::class)->isMember($user, $workspace)) { + abort(404); + } + + if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace)) { + abort(403); + } + + $this->resumeLatestOnboardingSessionIfUnambiguous(); + + $this->initializeWizardData(); + } + + public function content(Schema $schema): Schema + { + return $schema + ->statePath('data') + ->schema([ + Wizard::make([ + Step::make('Identify managed tenant') + ->description('Create or resume a pending managed tenant in this workspace.') + ->schema([ + Section::make('Tenant') + ->schema([ + TextInput::make('tenant_id') + ->label('Tenant ID (GUID)') + ->required() + ->rules(['uuid']) + ->maxLength(255), + TextInput::make('name') + ->label('Display name') + ->required() + ->maxLength(255), + ]), + ]) + ->afterValidation(function (): void { + $tenantGuid = (string) ($this->data['tenant_id'] ?? ''); + $tenantName = (string) ($this->data['name'] ?? ''); + + try { + $this->identifyManagedTenant([ + 'tenant_id' => $tenantGuid, + 'name' => $tenantName, + ]); + } catch (NotFoundHttpException) { + Notification::make() + ->title('Tenant not available') + ->body('This tenant cannot be onboarded in this workspace.') + ->danger() + ->send(); + + throw new Halt; + } + + $this->initializeWizardData(); + }), + + Step::make('Provider connection') + ->description('Select an existing connection or create a new one.') + ->schema([ + Section::make('Connection') + ->schema([ + Radio::make('connection_mode') + ->label('Mode') + ->options([ + 'existing' => 'Use existing connection', + 'new' => 'Create new connection', + ]) + ->required() + ->default('existing') + ->live(), + Select::make('provider_connection_id') + ->label('Provider connection') + ->required(fn (Get $get): bool => $get('connection_mode') === 'existing') + ->options(fn (): array => $this->providerConnectionOptions()) + ->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'), + + TextInput::make('new_connection.display_name') + ->label('Display name') + ->required(fn (Get $get): bool => $get('connection_mode') === 'new') + ->maxLength(255) + ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), + TextInput::make('new_connection.client_id') + ->label('Client ID') + ->required(fn (Get $get): bool => $get('connection_mode') === 'new') + ->maxLength(255) + ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), + TextInput::make('new_connection.client_secret') + ->label('Client secret') + ->password() + ->required(fn (Get $get): bool => $get('connection_mode') === 'new') + ->maxLength(255) + ->visible(fn (Get $get): bool => $get('connection_mode') === 'new') + ->helperText('Stored encrypted and never shown again.'), + Toggle::make('new_connection.is_default') + ->label('Make default') + ->default(true) + ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), + ]), + ]) + ->afterValidation(function (): void { + if (! $this->managedTenant instanceof Tenant) { + throw new Halt; + } + + $mode = (string) ($this->data['connection_mode'] ?? 'existing'); + + if ($mode === 'new') { + $new = is_array($this->data['new_connection'] ?? null) ? $this->data['new_connection'] : []; + + $this->createProviderConnection([ + 'display_name' => (string) ($new['display_name'] ?? ''), + 'client_id' => (string) ($new['client_id'] ?? ''), + 'client_secret' => (string) ($new['client_secret'] ?? ''), + 'is_default' => (bool) ($new['is_default'] ?? true), + ]); + + if (is_array($this->data['new_connection'] ?? null)) { + $this->data['new_connection']['client_secret'] = null; + } + } else { + $providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0); + + if ($providerConnectionId <= 0) { + throw new Halt; + } + + $this->selectProviderConnection($providerConnectionId); + } + + $this->touchOnboardingSessionStep('connection'); + $this->initializeWizardData(); + }), + + Step::make('Verify access') + ->description('Run a queued verification check (Operation Run).') + ->schema([ + Section::make('Verification') + ->schema([ + Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel()) + ->badge() + ->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'), + SchemaActions::make([ + Action::make('wizardStartVerification') + ->label('Start verification') + ->visible(fn (): bool => $this->managedTenant instanceof Tenant) + ->action(fn () => $this->startVerification()), + Action::make('wizardViewVerificationRun') + ->label('View run') + ->url(fn (): ?string => $this->verificationRunUrl()) + ->visible(fn (): bool => $this->verificationRunUrl() !== null), + ]), + ]), + ]) + ->beforeValidation(function (): void { + if (! $this->verificationHasSucceeded()) { + Notification::make() + ->title('Verification required') + ->body('Run verification successfully before continuing.') + ->warning() + ->send(); + + throw new Halt; + } + + $this->touchOnboardingSessionStep('verify'); + }), + + Step::make('Bootstrap (optional)') + ->description('Optionally start inventory and compliance operations.') + ->schema([ + Section::make('Bootstrap') + ->schema([ + CheckboxList::make('bootstrap_operation_types') + ->label('Bootstrap actions') + ->options(fn (): array => $this->bootstrapOperationOptions()) + ->columns(1), + SchemaActions::make([ + Action::make('wizardStartBootstrap') + ->label('Start bootstrap') + ->visible(fn (): bool => $this->managedTenant instanceof Tenant) + ->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))), + ]), + Text::make(fn (): string => $this->bootstrapRunsLabel()) + ->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''), + ]), + ]) + ->afterValidation(function (): void { + $types = $this->data['bootstrap_operation_types'] ?? []; + $this->selectedBootstrapOperationTypes = is_array($types) + ? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')) + : []; + + $this->touchOnboardingSessionStep('bootstrap'); + }), + + Step::make('Complete') + ->description('Activate the tenant and finish onboarding.') + ->schema([ + Section::make('Finish') + ->schema([ + Text::make(fn (): string => $this->managedTenant instanceof Tenant + ? 'Tenant: '.$this->managedTenant->name + : 'Tenant: not selected') + ->badge() + ->color('gray'), + Text::make(fn (): string => 'Verification: '.($this->verificationHasSucceeded() ? 'succeeded' : 'not yet succeeded')) + ->badge() + ->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'), + SchemaActions::make([ + Action::make('wizardCompleteOnboarding') + ->label('Complete onboarding') + ->color('success') + ->disabled(fn (): bool => ! $this->verificationHasSucceeded()) + ->action(fn () => $this->completeOnboarding()), + ]), + ]), + ]) + ->beforeValidation(function (): void { + if (! $this->verificationHasSucceeded()) { + throw new Halt; + } + }), + ]) + ->startOnStep(fn (): int => $this->computeWizardStartStep()) + ->skippable(false), + ]); + } + + private function resumeLatestOnboardingSessionIfUnambiguous(): void + { + $sessionCount = TenantOnboardingSession::query() + ->where('workspace_id', (int) $this->workspace->getKey()) + ->whereNull('completed_at') + ->count(); + + if ($sessionCount !== 1) { + return; + } + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $this->workspace->getKey()) + ->whereNull('completed_at') + ->orderByDesc('updated_at') + ->first(); + + if (! $session instanceof TenantOnboardingSession) { + return; + } + + $tenant = Tenant::query() + ->where('workspace_id', (int) $this->workspace->getKey()) + ->whereKey((int) $session->tenant_id) + ->first(); + + if (! $tenant instanceof Tenant) { + return; + } + + $this->managedTenant = $tenant; + $this->onboardingSession = $session; + + $providerConnectionId = $session->state['provider_connection_id'] ?? null; + $this->selectedProviderConnectionId = is_int($providerConnectionId) + ? $providerConnectionId + : $this->resolveDefaultProviderConnectionId($tenant); + + $bootstrapTypes = $session->state['bootstrap_operation_types'] ?? []; + $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) + ? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) + : []; + } + + private function initializeWizardData(): void + { + if (! array_key_exists('connection_mode', $this->data)) { + $this->data['connection_mode'] = 'existing'; + } + + if ($this->managedTenant instanceof Tenant) { + $this->data['tenant_id'] ??= (string) $this->managedTenant->tenant_id; + $this->data['name'] ??= (string) $this->managedTenant->name; + } + + if ($this->onboardingSession instanceof TenantOnboardingSession) { + $providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null; + if (is_int($providerConnectionId)) { + $this->data['provider_connection_id'] = $providerConnectionId; + $this->selectedProviderConnectionId = $providerConnectionId; + } + + $types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null; + if (is_array($types)) { + $this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')); + } + } + + if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) { + $this->data['provider_connection_id'] = $this->selectedProviderConnectionId; + } + } + + private function computeWizardStartStep(): int + { + if (! $this->managedTenant instanceof Tenant) { + return 1; + } + + if (! $this->resolveSelectedProviderConnection($this->managedTenant)) { + return 2; + } + + if (! $this->verificationHasSucceeded()) { + return 3; + } + + return 4; + } + + /** + * @return array + */ + private function providerConnectionOptions(): array + { + if (! $this->managedTenant instanceof Tenant) { + return []; + } + + return ProviderConnection::query() + ->where('tenant_id', $this->managedTenant->getKey()) + ->orderByDesc('is_default') + ->orderBy('display_name') + ->pluck('display_name', 'id') + ->all(); + } + + private function verificationStatusLabel(): string + { + if (! $this->managedTenant instanceof Tenant) { + return 'not started'; + } + + if ($this->verificationHasSucceeded()) { + return 'succeeded'; + } + + $runId = $this->onboardingSession?->state['verification_operation_run_id'] ?? null; + + return is_int($runId) ? 'running or failed' : 'not started'; + } + + private function verificationRunUrl(): ?string + { + if (! $this->managedTenant instanceof Tenant) { + return null; + } + + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return null; + } + + $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; + + if (! is_int($runId)) { + return null; + } + + return OperationRunLinks::view($runId, $this->managedTenant); + } + + private function bootstrapRunsLabel(): string + { + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return ''; + } + + $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; + $runs = is_array($runs) ? $runs : []; + + if ($runs === []) { + return ''; + } + + return sprintf('Started %d bootstrap run(s).', count($runs)); + } + + private function touchOnboardingSessionStep(string $step): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return; + } + + $this->onboardingSession->forceFill([ + 'current_step' => $step, + 'updated_by_user_id' => (int) $user->getKey(), + ])->save(); + } + + private function authorizeWorkspaceMutation(User $user): void + { + if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) { + abort(404); + } + + if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $this->workspace)) { + abort(403); + } + } + + private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int + { + $workspaceId = DB::table('tenant_memberships') + ->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id') + ->where('tenant_memberships.tenant_id', (int) $tenant->getKey()) + ->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END") + ->value('workspace_memberships.workspace_id'); + + return $workspaceId === null ? null : (int) $workspaceId; + } + + /** + * @param array{tenant_id: string, name: string} $data + */ + public function identifyManagedTenant(array $data): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceMutation($user); + + $tenantGuid = $data['tenant_id']; + $tenantName = $data['name']; + + DB::transaction(function () use ($user, $tenantGuid, $tenantName): void { + $auditLogger = app(WorkspaceAuditLogger::class); + $membershipManager = app(TenantMembershipManager::class); + + $existingTenant = Tenant::query() + ->withTrashed() + ->where('tenant_id', $tenantGuid) + ->first(); + + if ($existingTenant instanceof Tenant) { + if ($existingTenant->trashed() || $existingTenant->status === 'archived') { + abort(404); + } + + if ($existingTenant->workspace_id === null) { + $resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant); + + if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) { + $existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save(); + } + } + + if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { + abort(404); + } + + if ($existingTenant->name !== $tenantName) { + $existingTenant->forceFill(['name' => $tenantName])->save(); + } + + $tenant = $existingTenant; + } else { + try { + $tenant = Tenant::query()->create([ + 'workspace_id' => (int) $this->workspace->getKey(), + 'name' => $tenantName, + 'tenant_id' => $tenantGuid, + 'environment' => 'other', + 'status' => 'pending', + ]); + } catch (QueryException $exception) { + // Race-safe global uniqueness: if another workspace created the tenant_id first, + // treat it as deny-as-not-found. + $existingTenant = Tenant::query() + ->withTrashed() + ->where('tenant_id', $tenantGuid) + ->first(); + + if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { + abort(404); + } + + throw $exception; + } + } + + $membershipManager->addMember( + tenant: $tenant, + actor: $user, + member: $user, + role: 'owner', + source: 'manual', + ); + + $ownerCount = TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('role', 'owner') + ->count(); + + if ($ownerCount === 0) { + throw new RuntimeException('Tenant must have at least one owner.'); + } + + $session = TenantOnboardingSession::query() + ->where('workspace_id', $this->workspace->getKey()) + ->where('tenant_id', $tenant->getKey()) + ->first(); + + $sessionWasCreated = false; + + if (! $session instanceof TenantOnboardingSession) { + $session = new TenantOnboardingSession; + $session->workspace_id = (int) $this->workspace->getKey(); + $session->tenant_id = (int) $tenant->getKey(); + $session->started_by_user_id = (int) $user->getKey(); + $sessionWasCreated = true; + } + + $session->current_step = 'identify'; + $session->state = array_merge($session->state ?? [], [ + 'tenant_id' => $tenantGuid, + ]); + $session->updated_by_user_id = (int) $user->getKey(); + $session->save(); + + $this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant); + + if ($this->selectedProviderConnectionId !== null) { + $session->state = array_merge($session->state ?? [], [ + 'provider_connection_id' => (int) $this->selectedProviderConnectionId, + ]); + $session->save(); + } + + $auditLogger->log( + workspace: $this->workspace, + action: ($sessionWasCreated + ? AuditActionId::ManagedTenantOnboardingStart + : AuditActionId::ManagedTenantOnboardingResume + )->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $this->workspace->getKey(), + 'tenant_db_id' => (int) $tenant->getKey(), + 'tenant_guid' => $tenantGuid, + 'tenant_name' => $tenantName, + 'onboarding_session_id' => (int) $session->getKey(), + 'current_step' => (string) $session->current_step, + ], + ], + actor: $user, + status: 'success', + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); + + $this->managedTenant = $tenant; + $this->onboardingSession = $session; + }); + + Notification::make() + ->title('Managed tenant identified') + ->success() + ->send(); + } + + public function selectProviderConnection(int $providerConnectionId): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceMutation($user); + + if (! $this->managedTenant instanceof Tenant) { + abort(404); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $this->managedTenant->getKey()) + ->whereKey($providerConnectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + abort(404); + } + + $this->selectedProviderConnectionId = (int) $connection->getKey(); + + if ($this->onboardingSession instanceof TenantOnboardingSession) { + $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + $this->onboardingSession->current_step = 'connection'; + $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); + $this->onboardingSession->save(); + } + + Notification::make() + ->title('Provider connection selected') + ->success() + ->send(); + } + + /** + * @param array{display_name: string, client_id: string, client_secret: string, is_default?: bool} $data + */ + public function createProviderConnection(array $data): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceMutation($user); + + if (! $this->managedTenant instanceof Tenant) { + abort(404); + } + + $tenant = $this->managedTenant->fresh(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) { + abort(404); + } + + $displayName = trim((string) ($data['display_name'] ?? '')); + $clientId = (string) ($data['client_id'] ?? ''); + $clientSecret = (string) ($data['client_secret'] ?? ''); + $makeDefault = (bool) ($data['is_default'] ?? false); + + if ($displayName === '') { + abort(422); + } + + /** @var ProviderConnection $connection */ + $connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault): ProviderConnection { + $connection = ProviderConnection::query()->updateOrCreate( + [ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + ], + [ + 'display_name' => $displayName, + ], + ); + + app(CredentialManager::class)->upsertClientSecretCredential( + connection: $connection, + clientId: $clientId, + clientSecret: $clientSecret, + ); + + if ($makeDefault) { + $connection->makeDefault(); + } + + return $connection; + }); + + $this->selectedProviderConnectionId = (int) $connection->getKey(); + + if ($this->onboardingSession instanceof TenantOnboardingSession) { + $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + $this->onboardingSession->current_step = 'connection'; + $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); + $this->onboardingSession->save(); + } + + Notification::make() + ->title('Provider connection created') + ->success() + ->send(); + } + + public function startVerification(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceMutation($user); + + if (! $this->managedTenant instanceof Tenant) { + Notification::make() + ->title('Identify a managed tenant first') + ->warning() + ->send(); + + return; + } + + $tenant = $this->managedTenant->fresh(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $connection = $this->resolveSelectedProviderConnection($tenant); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('No provider connection selected') + ->body('Create or select a provider connection first.') + ->warning() + ->send(); + + return; + } + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + }, + initiator: $user, + extraContext: [ + 'wizard' => [ + 'flow' => 'managed_tenant_onboarding', + 'step' => 'verification', + ], + ], + ); + + if ($this->onboardingSession instanceof TenantOnboardingSession) { + $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $result->run->getKey(), + ]); + $this->onboardingSession->current_step = 'verify'; + $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); + $this->onboardingSession->save(); + } + + $auditStatus = match ($result->status) { + 'started' => 'success', + 'deduped' => 'deduped', + 'scope_busy' => 'blocked', + default => 'success', + }; + + app(WorkspaceAuditLogger::class)->log( + workspace: $this->workspace, + action: AuditActionId::ManagedTenantOnboardingVerificationStart->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $this->workspace->getKey(), + 'tenant_db_id' => (int) $tenant->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'operation_run_id' => (int) $result->run->getKey(), + 'result' => (string) $result->status, + ], + ], + actor: $user, + status: $auditStatus, + resourceType: 'operation_run', + resourceId: (string) $result->run->getKey(), + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Another operation is already running') + ->body('Please wait for the active run to finish.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started') + ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + } + + /** + * @param array $operationTypes + */ + public function startBootstrap(array $operationTypes): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceMutation($user); + + if (! $this->managedTenant instanceof Tenant) { + abort(404); + } + + $tenant = $this->managedTenant->fresh(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $this->verificationHasSucceeded()) { + Notification::make() + ->title('Verification required') + ->body('Run verification successfully before starting bootstrap actions.') + ->warning() + ->send(); + + return; + } + + $connection = $this->resolveSelectedProviderConnection($tenant); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('No provider connection selected') + ->warning() + ->send(); + + return; + } + + $registry = app(ProviderOperationRegistry::class); + $types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== ''))); + + $types = array_values(array_filter( + $types, + static fn (string $type): bool => $type !== 'provider.connection.check' && $registry->isAllowed($type), + )); + + if (empty($types)) { + Notification::make() + ->title('No bootstrap actions selected') + ->warning() + ->send(); + + return; + } + + /** @var array{status: 'started', runs: array}|array{status: 'scope_busy', run: OperationRun} $result */ + $result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array { + $lockedConnection = ProviderConnection::query() + ->whereKey($connection->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $activeRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->active() + ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) + ->orderByDesc('id') + ->first(); + + if ($activeRun instanceof OperationRun) { + return [ + 'status' => 'scope_busy', + 'run' => $activeRun, + ]; + } + + $runsService = app(OperationRunService::class); + + $bootstrapRuns = []; + + foreach ($types as $operationType) { + $definition = $registry->get($operationType); + + $context = [ + 'wizard' => [ + 'flow' => 'managed_tenant_onboarding', + 'step' => 'bootstrap', + ], + 'provider' => $lockedConnection->provider, + 'module' => $definition['module'], + 'provider_connection_id' => (int) $lockedConnection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $lockedConnection->entra_tenant_id, + ], + ]; + + $run = $runsService->ensureRunWithIdentity( + tenant: $tenant, + type: $operationType, + identityInputs: [ + 'provider_connection_id' => (int) $lockedConnection->getKey(), + ], + context: $context, + initiator: $user, + ); + + if ($run->wasRecentlyCreated) { + $this->dispatchBootstrapJob( + operationType: $operationType, + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $lockedConnection->getKey(), + run: $run, + ); + } + + $bootstrapRuns[$operationType] = (int) $run->getKey(); + } + + return [ + 'status' => 'started', + 'runs' => $bootstrapRuns, + ]; + }); + + if ($result['status'] === 'scope_busy') { + Notification::make() + ->title('Another operation is already running') + ->body('Please wait for the active run to finish.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result['run'], $tenant)), + ]) + ->send(); + + return; + } + + $bootstrapRuns = $result['runs']; + + if ($this->onboardingSession instanceof TenantOnboardingSession) { + $state = $this->onboardingSession->state ?? []; + + $existing = $state['bootstrap_operation_runs'] ?? []; + $existing = is_array($existing) ? $existing : []; + + $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); + $state['bootstrap_operation_types'] = $types; + + $this->onboardingSession->state = $state; + $this->onboardingSession->current_step = 'bootstrap'; + $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); + $this->onboardingSession->save(); + } + + Notification::make() + ->title('Bootstrap started') + ->success() + ->send(); + } + + private function dispatchBootstrapJob( + string $operationType, + int $tenantId, + int $userId, + int $providerConnectionId, + OperationRun $run, + ): void { + match ($operationType) { + 'inventory.sync' => ProviderInventorySyncJob::dispatch( + tenantId: $tenantId, + userId: $userId, + providerConnectionId: $providerConnectionId, + operationRun: $run, + ), + 'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch( + tenantId: $tenantId, + userId: $userId, + providerConnectionId: $providerConnectionId, + operationRun: $run, + ), + default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"), + }; + } + + public function verificationSucceeded(): bool + { + return $this->verificationHasSucceeded(); + } + + public function completeOnboarding(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $this->authorizeWorkspaceMutation($user); + + if (! $this->managedTenant instanceof Tenant) { + abort(404); + } + + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + abort(404); + } + + if (! $this->verificationHasSucceeded()) { + Notification::make() + ->title('Verification required') + ->body('Complete verification successfully before finishing onboarding.') + ->warning() + ->send(); + + return; + } + + $tenant = $this->managedTenant->fresh(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + DB::transaction(function () use ($tenant, $user): void { + $tenant->update(['status' => 'active']); + + $this->onboardingSession->forceFill([ + 'completed_at' => now(), + 'current_step' => 'complete', + 'updated_by_user_id' => (int) $user->getKey(), + ])->save(); + }); + + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + } + + private function verificationHasSucceeded(): bool + { + if (! $this->managedTenant instanceof Tenant) { + return false; + } + + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return false; + } + + $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; + + if (! is_int($runId)) { + return false; + } + + $run = OperationRun::query() + ->where('tenant_id', (int) $this->managedTenant->getKey()) + ->whereKey($runId) + ->first(); + + if (! $run instanceof OperationRun) { + return false; + } + + return $run->status === 'completed' && $run->outcome === 'succeeded'; + } + + /** + * @return array + */ + private function bootstrapOperationOptions(): array + { + $registry = app(ProviderOperationRegistry::class); + + return collect($registry->all()) + ->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check') + ->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)]) + ->all(); + } + + private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int + { + $id = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('is_default', true) + ->orderByDesc('id') + ->value('id'); + + if (is_int($id)) { + return $id; + } + + $fallback = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->orderByDesc('id') + ->value('id'); + + return is_int($fallback) ? $fallback : null; + } + + private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderConnection + { + $providerConnectionId = $this->selectedProviderConnectionId; + + if (! is_int($providerConnectionId) && $this->onboardingSession instanceof TenantOnboardingSession) { + $candidate = $this->onboardingSession->state['provider_connection_id'] ?? null; + $providerConnectionId = is_int($candidate) ? $candidate : null; + } + + if (! is_int($providerConnectionId)) { + $providerConnectionId = $this->resolveDefaultProviderConnectionId($tenant); + } + + if (! is_int($providerConnectionId)) { + return null; + } + + return ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereKey($providerConnectionId) + ->first(); + } +} diff --git a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php index 922ee8b..fd6405d 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -5,7 +5,6 @@ namespace App\Filament\Pages\Workspaces; use App\Filament\Pages\ChooseTenant; -use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage; use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\User; @@ -48,11 +47,6 @@ public function getTenants(): Collection ->get(); } - public function canRegisterTenant(): bool - { - return RegisterTenantPage::canView(); - } - public function goToChooseTenant(): void { $this->redirect(ChooseTenant::getUrl()); diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php index e2e268e..e3c8b75 100644 --- a/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -17,6 +17,8 @@ class WorkspaceResource extends Resource { protected static ?string $model = Workspace::class; + protected static bool $isDiscovered = false; + protected static bool $isScopedToTenant = false; protected static ?string $recordTitleAttribute = 'name'; diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 15dbab8..189b232 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse $tenantCount = (int) $tenantsQuery->count(); if ($tenantCount === 0) { - return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]); } if ($tenantCount === 1) { diff --git a/app/Models/TenantOnboardingSession.php b/app/Models/TenantOnboardingSession.php new file mode 100644 index 0000000..aec827b --- /dev/null +++ b/app/Models/TenantOnboardingSession.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + protected $table = 'managed_tenant_onboarding_sessions'; + + protected $guarded = []; + + protected $casts = [ + 'state' => 'array', + 'completed_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function startedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'started_by_user_id'); + } + + /** + * @return BelongsTo + */ + public function updatedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by_user_id'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index c75f6b0..bd4e9a4 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -6,8 +6,10 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; use App\Policies\ProviderConnectionPolicy; use App\Services\Auth\CapabilityResolver; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; use App\Support\Auth\PlatformCapabilities; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; @@ -23,19 +25,36 @@ public function boot(): void { $this->registerPolicies(); - $resolver = app(CapabilityResolver::class); + $tenantResolver = app(CapabilityResolver::class); + $workspaceResolver = app(WorkspaceCapabilityResolver::class); - $defineTenantCapability = function (string $capability) use ($resolver): void { - Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool { + $defineTenantCapability = function (string $capability) use ($tenantResolver): void { + Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool { if (! $tenant instanceof Tenant) { return false; } - return $resolver->can($user, $tenant, $capability); + return $tenantResolver->can($user, $tenant, $capability); + }); + }; + + $defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void { + Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool { + if (! $workspace instanceof Workspace) { + return false; + } + + return $workspaceResolver->can($user, $workspace, $capability); }); }; foreach (Capabilities::all() as $capability) { + if (str_starts_with($capability, 'workspace')) { + $defineWorkspaceCapability($capability); + + continue; + } + $defineTenantCapability($capability); } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 406ec27..fb1de8c 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -6,8 +6,8 @@ use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\NoAccess; -use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\TenantDashboard; +use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\Tenant; use App\Support\Middleware\DenyNonMemberTenantAccess; use Filament\Facades\Filament; @@ -42,25 +42,20 @@ public function panel(Panel $panel): Panel ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); + + WorkspaceResource::registerRoutes($panel); }) ->tenant(Tenant::class, slugAttribute: 'external_id') ->tenantRoutePrefix('t') ->tenantMenu(fn (): bool => filled(Filament::getTenant())) ->searchableTenantMenu() - ->tenantRegistration(RegisterTenant::class) ->colors([ 'primary' => Color::Amber, ]) ->navigationItems([ NavigationItem::make('Workspaces') ->url(function (): string { - $tenant = Filament::getTenant(); - - if ($tenant instanceof Tenant) { - return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]); - } - - return ChooseWorkspace::getUrl(); + return route('filament.admin.resources.workspaces.index'); }) ->icon('heroicon-o-squares-2x2') ->group('Settings') diff --git a/app/Services/Audit/WorkspaceAuditLogger.php b/app/Services/Audit/WorkspaceAuditLogger.php index f70f489..4110aaa 100644 --- a/app/Services/Audit/WorkspaceAuditLogger.php +++ b/app/Services/Audit/WorkspaceAuditLogger.php @@ -7,6 +7,7 @@ use App\Models\AuditLog; use App\Models\User; use App\Models\Workspace; +use App\Support\Audit\AuditContextSanitizer; use Carbon\CarbonImmutable; class WorkspaceAuditLogger @@ -26,6 +27,10 @@ public function log( $metadata = $context['metadata'] ?? []; unset($context['metadata']); + $metadata = is_array($metadata) ? $metadata : []; + + $sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context); + return AuditLog::create([ 'tenant_id' => null, 'workspace_id' => (int) $workspace->getKey(), @@ -36,7 +41,7 @@ public function log( 'resource_type' => $resourceType, 'resource_id' => $resourceId, 'status' => $status, - 'metadata' => $metadata + $context, + 'metadata' => $sanitizedMetadata, 'recorded_at' => CarbonImmutable::now(), ]); } diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php index 9e78923..6a93065 100644 --- a/app/Services/Auth/WorkspaceRoleCapabilityMap.php +++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php @@ -23,12 +23,14 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_ARCHIVE, Capabilities::WORKSPACE_MEMBERSHIP_VIEW, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, ], WorkspaceRole::Manager->value => [ Capabilities::WORKSPACE_VIEW, Capabilities::WORKSPACE_MEMBERSHIP_VIEW, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, ], WorkspaceRole::Operator->value => [ diff --git a/app/Services/Intune/AuditLogger.php b/app/Services/Intune/AuditLogger.php index 9e2e7c7..a5a14ae 100644 --- a/app/Services/Intune/AuditLogger.php +++ b/app/Services/Intune/AuditLogger.php @@ -4,6 +4,7 @@ use App\Models\AuditLog; use App\Models\Tenant; +use App\Support\Audit\AuditContextSanitizer; use Carbon\CarbonImmutable; class AuditLogger @@ -22,6 +23,10 @@ public function log( $metadata = $context['metadata'] ?? []; unset($context['metadata']); + $metadata = is_array($metadata) ? $metadata : []; + + $sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context); + return AuditLog::create([ 'tenant_id' => $tenant->id, 'actor_id' => $actorId, @@ -31,7 +36,7 @@ public function log( 'resource_type' => $resourceType, 'resource_id' => $resourceId, 'status' => $status, - 'metadata' => $metadata + $context, + 'metadata' => $sanitizedMetadata, 'recorded_at' => CarbonImmutable::now(), ]); } diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index c1a8ff5..16c1302 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -22,4 +22,9 @@ enum AuditActionId: string // Diagnostics / repair actions. case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged'; + + // Managed tenant onboarding wizard. + case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start'; + case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume'; + case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start'; } diff --git a/app/Support/Audit/AuditContextSanitizer.php b/app/Support/Audit/AuditContextSanitizer.php new file mode 100644 index 0000000..3616fde --- /dev/null +++ b/app/Support/Audit/AuditContextSanitizer.php @@ -0,0 +1,66 @@ + $item) { + if (is_string($key) && self::shouldRedactKey($key)) { + $sanitized[$key] = self::REDACTED; + + continue; + } + + $sanitized[$key] = self::sanitize($item); + } + + return $sanitized; + } + + if (is_string($value)) { + return self::sanitizeString($value); + } + + return $value; + } + + private static function shouldRedactKey(string $key): bool + { + $key = strtolower(trim($key)); + + return str_contains($key, 'token') + || str_contains($key, 'secret') + || str_contains($key, 'password') + || str_contains($key, 'authorization') + || str_contains($key, 'private_key') + || str_contains($key, 'client_secret'); + } + + private static function sanitizeString(string $value): string + { + $candidate = trim($value); + + if ($candidate === '') { + return $value; + } + + if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) { + return self::REDACTED; + } + + if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) { + return self::REDACTED; + } + + return $value; + } +} diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index a17b94b..609ffe9 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -27,6 +27,9 @@ class Capabilities public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage'; + // Managed tenant onboarding + public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard'; + // Tenants public const TENANT_VIEW = 'tenant.view'; diff --git a/app/Support/Badges/Domains/TenantStatusBadge.php b/app/Support/Badges/Domains/TenantStatusBadge.php index 6b48320..451735e 100644 --- a/app/Support/Badges/Domains/TenantStatusBadge.php +++ b/app/Support/Badges/Domains/TenantStatusBadge.php @@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec $state = BadgeCatalog::normalizeState($value); return match ($state) { + 'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'), 'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'), 'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'), 'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'), diff --git a/database/migrations/2026_02_03_090449_create_tenant_onboarding_sessions_table.php b/database/migrations/2026_02_03_090449_create_tenant_onboarding_sessions_table.php new file mode 100644 index 0000000..4bf5b54 --- /dev/null +++ b/database/migrations/2026_02_03_090449_create_tenant_onboarding_sessions_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->string('current_step')->nullable(); + $table->json('state')->nullable(); + + $table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->unique(['workspace_id', 'tenant_id']); + $table->index(['tenant_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('managed_tenant_onboarding_sessions'); + } +}; diff --git a/database/migrations/2026_02_03_090522_enforce_tenant_workspace_binding.php b/database/migrations/2026_02_03_090522_enforce_tenant_workspace_binding.php new file mode 100644 index 0000000..87fc278 --- /dev/null +++ b/database/migrations/2026_02_03_090522_enforce_tenant_workspace_binding.php @@ -0,0 +1,128 @@ +whereNull('workspace_id')->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $workspaceId = DB::table('tenant_memberships') + ->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id') + ->where('tenant_memberships.tenant_id', $tenantId) + ->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END") + ->value('workspace_memberships.workspace_id'); + + if ($workspaceId !== null) { + DB::table('tenants') + ->where('id', $tenantId) + ->update(['workspace_id' => (int) $workspaceId]); + } + } + + $remaining = (int) DB::table('tenants')->whereNull('workspace_id')->count(); + + if ($remaining === 0) { + return; + } + + $legacyWorkspaceId = DB::table('workspaces')->insertGetId([ + 'name' => 'Legacy Workspace', + 'slug' => 'legacy', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $users = DB::table('tenant_memberships') + ->join('tenants', 'tenants.id', '=', 'tenant_memberships.tenant_id') + ->whereNull('tenants.workspace_id') + ->select([ + 'tenant_memberships.user_id', + DB::raw("MIN(CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END) AS role_rank"), + ]) + ->groupBy('tenant_memberships.user_id') + ->get(); + + $roleFromRank = static fn (int $rank): string => match ($rank) { + 0 => 'owner', + 1 => 'manager', + 2 => 'operator', + default => 'readonly', + }; + + $membershipRows = []; + + foreach ($users as $user) { + $membershipRows[] = [ + 'workspace_id' => (int) $legacyWorkspaceId, + 'user_id' => (int) $user->user_id, + 'role' => $roleFromRank((int) $user->role_rank), + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if ($membershipRows !== []) { + DB::table('workspace_memberships')->insertOrIgnore($membershipRows); + } + + DB::table('tenants') + ->whereNull('workspace_id') + ->update(['workspace_id' => (int) $legacyWorkspaceId]); + }); + + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id SET NOT NULL'); + + return; + } + + if ($driver === 'mysql') { + DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NOT NULL'); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $driver = DB::getDriverName(); + + if ($driver === 'sqlite') { + return; + } + + if (! Schema::hasColumn('tenants', 'workspace_id')) { + return; + } + + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id DROP NOT NULL'); + + return; + } + + if ($driver === 'mysql') { + DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NULL'); + } + } +}; diff --git a/database/migrations/2026_02_03_150001_create_managed_tenant_onboarding_sessions_table.php b/database/migrations/2026_02_03_150001_create_managed_tenant_onboarding_sessions_table.php new file mode 100644 index 0000000..c0d7094 --- /dev/null +++ b/database/migrations/2026_02_03_150001_create_managed_tenant_onboarding_sessions_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->string('current_step')->nullable(); + $table->json('state')->nullable(); + + $table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->unique(['workspace_id', 'tenant_id']); + $table->index(['tenant_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('managed_tenant_onboarding_sessions'); + } +}; diff --git a/resources/views/filament/pages/choose-tenant.blade.php b/resources/views/filament/pages/choose-tenant.blade.php index 3a7efea..fc2cda6 100644 --- a/resources/views/filament/pages/choose-tenant.blade.php +++ b/resources/views/filament/pages/choose-tenant.blade.php @@ -13,25 +13,10 @@
No tenants are available for your account.
- @if ($this->canRegisterTenant()) - Register a tenant for this workspace, or switch workspaces. - @else - Switch workspaces, or contact an administrator. - @endif + Switch workspaces, or contact an administrator.
- @if ($this->canRegisterTenant()) - - Register tenant - - @endif - + +
+
+ Workspace: {{ $this->workspace->name }} +
+ +
+
+ Managed tenant onboarding +
+
+ This wizard will guide you through identifying a managed tenant and verifying access. +
+
+ + @if ($this->managedTenant) +
+
Identified tenant
+
+
+
Name
+
{{ $this->managedTenant->name }}
+
+
+
Tenant ID
+
{{ $this->managedTenant->tenant_id }}
+
+
+
+ @endif + + @php + $verificationSucceeded = $this->verificationSucceeded(); + $hasTenant = (bool) $this->managedTenant; + $hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0; + @endphp + +
+
+
+
+
Step 1 — Identify managed tenant
+
Provide tenant ID + display name to start or resume the flow.
+
+
+ {{ $hasTenant ? 'Done' : 'Pending' }} +
+
+ +
+ + {{ $hasTenant ? 'Change tenant' : 'Identify tenant' }} + +
+
+ +
+
+
+
Step 2 — Provider connection
+
Create or pick the connection used to verify access.
+
+
+ {{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }} +
+
+ + @if ($hasTenant) +
+ Selected connection ID +
{{ $this->selectedProviderConnectionId ?? '—' }}
+
+ +
+ + Create connection + + + + Select connection + +
+ @endif +
+ +
+
+
+
Step 3 — Verify access
+
Runs a verification operation and records the result.
+
+
+ {{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }} +
+
+ +
+ + Run verification + +
+
+ +
+
+
+
Step 4 — Bootstrap (optional)
+
Start inventory/compliance sync after verification.
+
+
+ {{ $verificationSucceeded ? 'Available' : 'Locked' }} +
+
+ +
+ + Start bootstrap + +
+
+
+ +
+
+
+
Step 5 — Complete onboarding
+
Marks the tenant as active after successful verification.
+
+
+ {{ $verificationSucceeded ? 'Ready' : 'Locked' }} +
+
+ +
+ + Complete onboarding + +
+
+
+
+ diff --git a/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php index 776dd02..239816b 100644 --- a/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php +++ b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php @@ -17,16 +17,14 @@
- @if ($this->canRegisterTenant()) - - Add managed tenant - - @endif + + Start onboarding + name('admin.consent.start'); // Panel root override: keep the app's workspace-first flow. -// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant +// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows. // when no default tenant can be resolved. Route::middleware([ 'web', @@ -67,7 +66,7 @@ $tenantCount = (int) $tenantsQuery->count(); if ($tenantCount === 0) { - return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]); } if ($tenantCount === 1) { @@ -81,23 +80,6 @@ return redirect()->to('/admin/choose-tenant'); }) ->name('admin.home'); -// Fallback route: Filament's layout generates this URL when tenancy registration is enabled. -// In this app, package route registration may not always define it early enough, which breaks -// rendering on tenant-scoped routes. -Route::middleware([ - 'web', - 'panel:admin', - 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - FilamentAuthenticate::class, - 'ensure-workspace-selected', -]) - ->prefix('/admin') - ->name('filament.admin.') - ->get('/register-tenant', RegisterTenant::class) - ->name('tenant.registration'); Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) ->name('admin.rbac.start'); @@ -112,42 +94,6 @@ ->middleware('throttle:entra-callback') ->name('auth.entra.callback'); -Route::middleware(['web', 'auth', 'ensure-workspace-selected']) - ->get('/admin/managed-tenants', function (Request $request) { - $workspace = app(WorkspaceContext::class)->currentWorkspace($request); - - if (! $workspace instanceof Workspace) { - return redirect('/admin/choose-workspace'); - } - - return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants'); - }) - ->name('admin.legacy.managed-tenants.index'); - -Route::middleware(['web', 'auth', 'ensure-workspace-selected']) - ->get('/admin/managed-tenants/onboarding', function (Request $request) { - $workspace = app(WorkspaceContext::class)->currentWorkspace($request); - - if (! $workspace instanceof Workspace) { - return redirect('/admin/choose-workspace'); - } - - return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding'); - }) - ->name('admin.legacy.managed-tenants.onboarding'); - -Route::middleware(['web', 'auth', 'ensure-workspace-selected']) - ->get('/admin/new', function (Request $request) { - $workspace = app(WorkspaceContext::class)->currentWorkspace($request); - - if (! $workspace instanceof Workspace) { - return redirect('/admin/choose-workspace'); - } - - return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding'); - }) - ->name('admin.legacy.onboarding'); - Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) ->post('/admin/switch-workspace', SwitchWorkspaceController::class) ->name('admin.switch-workspace'); @@ -173,11 +119,20 @@ ->name('admin.workspace.home'); Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); - - Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant')) - ->name('admin.workspace.managed-tenants.onboarding'); }); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', +]) + ->get('/admin/w/{workspace}/managed-tenants/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class) + ->name('admin.workspace.managed-tenants.onboarding'); + Route::middleware([ 'web', 'panel:admin', diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/checklists/requirements.md b/specs/073-unified-managed-tenant-onboarding-wizard/checklists/requirements.md new file mode 100644 index 0000000..e4a04da --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Unified Managed Tenant Onboarding Wizard (073) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-03 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All checklist items pass. +- The constitution-alignment paragraphs reference platform primitives (e.g., `OperationRun`) and domain integrations (e.g., Microsoft Graph) as required by this repository’s constitution. +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/contracts/http.openapi.yaml b/specs/073-unified-managed-tenant-onboarding-wizard/contracts/http.openapi.yaml new file mode 100644 index 0000000..81986b4 --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/contracts/http.openapi.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: TenantPilot — Managed Tenant Onboarding (073) + version: 0.1.0 + description: | + Workspace-scoped onboarding wizard routes. These are UI endpoints (Filament/Livewire), + but documented here for contract clarity. +servers: + - url: https://example.invalid +paths: + /admin/w/{workspace}/managed-tenants: + get: + summary: Managed tenants landing (workspace-scoped) + parameters: + - name: workspace + in: path + required: true + schema: + type: string + responses: + '200': + description: Renders managed tenants landing page. + '403': + description: Workspace member missing required capability (where applicable). + '404': + description: Workspace not found or user not a member (deny-as-not-found). + /admin/w/{workspace}/managed-tenants/onboarding: + get: + summary: Managed tenant onboarding wizard (workspace-scoped) + parameters: + - name: workspace + in: path + required: true + schema: + type: string + responses: + '200': + description: Renders onboarding wizard page. + '403': + description: Workspace member missing onboarding capability. + '404': + description: Workspace not found or user not a member (deny-as-not-found). + + /admin/register-tenant: + get: + summary: Legacy tenant registration entry point + deprecated: true + responses: + '404': + description: Must be removed / behave as not found (FR-001). diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/contracts/onboarding-actions.md b/specs/073-unified-managed-tenant-onboarding-wizard/contracts/onboarding-actions.md new file mode 100644 index 0000000..c05e391 --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/contracts/onboarding-actions.md @@ -0,0 +1,57 @@ +# Onboarding Wizard — Action Contracts (073) + +These are conceptual contracts for the wizard’s server-side actions (Livewire/Filament). +They define inputs/outputs and authorization semantics. + +## Identify tenant + +- **Purpose:** Upsert or resume a tenant onboarding session and ensure a single tenant record exists per `(workspace_id, entra_tenant_id)`. +- **Inputs:** + - `entra_tenant_id` (string) + - `name` (string) + - `domain` (string|null) +- **Outputs:** + - `tenant_id` (internal DB id) + - `onboarding_session_id` + - `current_step` +- **Errors:** + - 404: workspace not found or actor not a workspace member + - 403: actor is a workspace member but lacks onboarding capability + +## Select or create Provider Connection + +- **Purpose:** Attach an existing default connection (if present) or create/select another connection for the tenant. +- **Inputs:** + - `provider_connection_id` (int|null) + - (optional) connection creation fields (non-secret identifiers only) +- **Outputs:** + - `provider_connection_id` + - `is_default` +- **Errors:** + - 404: connection/tenant not in workspace scope + - 403: member missing capability + +## Start verification + +- **Purpose:** Start provider connection verification asynchronously. +- **Mechanism:** Create/reuse `OperationRun` of type `provider.connection.check`, enqueue `ProviderConnectionHealthCheckJob`. +- **Inputs:** none (uses selected connection) +- **Outputs:** + - `operation_run_id` + - `status` (queued/running/succeeded/failed) +- **Errors:** + - 404: tenant/connection not in workspace scope + - 403: member missing capability + +## Optional bootstrap actions + +- **Purpose:** Start selected post-verify operations as separate runs. +- **Inputs:** list of operation types (must exist in registry) +- **Outputs:** list of `operation_run_id` +- **Errors:** + - 403/404 semantics as above + +## Security & data minimization + +- Stored secrets must never be returned. +- Failures are stored as stable reason codes + sanitized messages. diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/data-model.md b/specs/073-unified-managed-tenant-onboarding-wizard/data-model.md new file mode 100644 index 0000000..5d0f8ee --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/data-model.md @@ -0,0 +1,84 @@ +# Data Model — Unified Managed Tenant Onboarding Wizard (073) + +## Entities + +### Workspace + +Existing entity. Onboarding is always initiated within a selected workspace. + +### Tenant (Managed Tenant) + +Existing model: `App\Models\Tenant` + +**Key fields (existing or to be confirmed/extended):** + +- `id` (PK) +- `workspace_id` (FK to workspaces) +- `tenant_id` (string; Entra tenant ID) — spec’s `entra_tenant_id` +- `external_id` (string; globally unique route key used by Filament tenancy) +- `name` (string) +- `domain` (string|null) +- `status` (string) — v1 lifecycle: + - `pending` (created / onboarding) + - `active` (ready) + - `archived` (no longer managed) + +**Indexes / constraints (design intent):** + +- Unique: `(workspace_id, tenant_id)` +- Keep `external_id` globally unique (for `/admin/t/{tenant}` routing) and do **not** force it to equal `tenant_id`. + +**State transitions:** + +- `pending` → `active` after successful verification +- `active` → `archived` on soft-delete (existing behavior) +- `archived` → `active` on restore (existing behavior) + +### ProviderConnection + +Existing model: `App\Models\ProviderConnection` + +- Belongs to `Tenant` +- Contains `entra_tenant_id` (string) and default/active flags. + +### TenantOnboardingSession (new) + +New model/table to persist resumable onboarding state. Must never persist or return secrets. + +**Proposed fields:** + +- `id` (PK) +- `workspace_id` (FK) +- `tenant_id` (FK to tenants.id) — nullable until tenant is created, depending on wizard flow +- `entra_tenant_id` (string) — denormalized for upsert/idempotency before tenant exists +- `current_step` (string; e.g., `identify`, `connection`, `verify`, `bootstrap`, `complete`) +- `state` (jsonb/json) — safe fields only (no secrets) + - `tenant_name` + - `tenant_domain` + - `selected_provider_connection_id` + - `verification_run_id` (OperationRun id) + - `bootstrap_run_ids` (array) +- `started_by_user_id` (FK users) +- `updated_by_user_id` (FK users) +- `completed_at` (timestamp|null) +- timestamps + +**Constraints:** + +- Unique: `(workspace_id, entra_tenant_id)` + +**State transitions:** + +- `in_progress` (implied by `completed_at = null`) → `completed` (`completed_at != null`) + +## Validation rules (high level) + +- `entra_tenant_id` (`tenant_id`) must be a non-empty string; validate as GUID format if enforced elsewhere. +- Tenant name required to create tenant. +- ProviderConnection selection must belong to the same tenant/workspace. + +## Authorization boundaries + +- Workspace scope: non-members denied as 404. +- Workspace member but missing onboarding capability: 403. +- Tenant scope: once tenant exists/selected, tenant membership rules apply as currently implemented. diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/plan.md b/specs/073-unified-managed-tenant-onboarding-wizard/plan.md new file mode 100644 index 0000000..6d66a4f --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/plan.md @@ -0,0 +1,163 @@ +# Implementation Plan: Unified Managed Tenant Onboarding Wizard (073) + +**Branch**: `073-unified-managed-tenant-onboarding-wizard` | **Date**: 2026-02-03 | **Spec**: specs/073-unified-managed-tenant-onboarding-wizard/spec.md +**Input**: Feature specification from `specs/073-unified-managed-tenant-onboarding-wizard/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Deliver a single, resumable onboarding wizard for Managed Tenants that: (1) identifies/upserts a managed tenant within the current workspace, (2) attaches or configures a Provider Connection, (3) runs verification asynchronously as an `OperationRun` with sanitized outcomes, and (4) optionally kicks off bootstrap operations. + +Implementation approach: reuse existing primitives (`App\Models\Tenant`, Provider Connections, `provider.connection.check` operation type, workspace + tenant isolation middleware, canonical capability registries) and replace legacy tenant registration/redirect entry points with a single workspace-scoped wizard route. + +## Technical Context + + + +**Language/Version**: PHP 8.4.x (Composer constraint: `^8.2`) +**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x +**Storage**: PostgreSQL (Sail) + SQLite in tests where applicable +**Testing**: Pest (via `vendor/bin/sail artisan test`) +**Target Platform**: Web app (Sail for local dev; container-based deploy on Linux) +**Project Type**: Web application (Laravel monolith) +**Performance Goals**: Onboarding UI renders DB-only; all Graph calls occur in queued work tracked by `OperationRun`; avoid N+1 via eager loading for any list/detail. +**Constraints**: Tenant isolation (404 vs 403 semantics); no secret material ever returned to the UI/logs; idempotent run-start and onboarding session resume; destructive-like actions require confirmation. +**Scale/Scope**: Workspace-scoped onboarding; expected low volume but high correctness/safety requirements. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + + +GATE RESULT: PASS (no planned constitution violations). + +- Inventory-first: onboarding writes only tenant metadata + configuration pointers; no inventory/snapshot side effects. +- Read/write separation: onboarding creates/updates records and starts operations; all mutating actions are authorized, audited, and tested. +- Graph contract path: verification uses existing `GraphClientInterface` methods (e.g., `getOrganization()`), and runs only in queued jobs. +- Deterministic capabilities: use `App\Support\Auth\Capabilities` + `WorkspaceRoleCapabilityMap`; add a dedicated onboarding capability granted to Owner+Manager. +- RBAC-UX semantics: workspace membership enforced via `ensure-workspace-member`; tenant membership enforced via `EnsureFilamentTenantSelected` / `DenyNonMemberTenantAccess` with deny-as-not-found (404). Missing capability returns 403. +- Destructive confirmation: any archive/delete/credential-rotation actions involved in onboarding must be `->action(...)->requiresConfirmation()`. +- Run observability: verification + optional bootstrap actions start via `OperationRun` and enqueue only; monitoring pages remain DB-only. +- Data minimization: onboarding session stores only non-secret fields; run failures store reason codes + sanitized messages. +- BADGE-001: introduce/extend Managed Tenant status badges via `BadgeCatalog` domain mapping (no per-page mapping). + +## Project Structure + +### Documentation (this feature) + +```text +specs/073-unified-managed-tenant-onboarding-wizard/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ │ └── Workspaces/ +│ │ ├── ManagedTenantsLanding.php +│ │ └── (new) ManagedTenantOnboardingWizard.php +│ └── Pages/Tenancy/ +│ └── RegisterTenant.php # legacy entry point to remove/disable +├── Http/Controllers/ +│ └── TenantOnboardingController.php # legacy admin-consent helper; evaluate usage +├── Jobs/ +│ └── ProviderConnectionHealthCheckJob.php # verification via OperationRun +├── Models/ +│ ├── Tenant.php +│ ├── ProviderConnection.php +│ └── (new) TenantOnboardingSession.php +└── Services/ + ├── Auth/ + │ ├── WorkspaceCapabilityResolver.php + │ └── WorkspaceRoleCapabilityMap.php + ├── Providers/ + │ ├── ProviderOperationRegistry.php + │ └── ProviderGateway.php + └── Graph/ + └── GraphClientInterface.php + +database/migrations/ +├── (new) *_add_workspace_scoped_unique_tenant_id.php +└── (new) *_create_tenant_onboarding_sessions_table.php + +routes/web.php + +tests/Feature/ +└── (new) ManagedTenantOnboardingWizardTest.php +``` + +**Structure Decision**: Laravel web application (monolith). Onboarding wizard is a Filament page mounted on a workspace-scoped route under `/admin/w/{workspace}/...` (no tenant context required to start). + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | + +No constitution violations are anticipated for this feature. + +## Phase 0 — Outline & Research (complete) + +Outputs: + +- `research.md`: decisions + rationale + alternatives (no unresolved clarifications). + +Key research conclusions: + +- Reuse `App\Models\Tenant` as “Managed Tenant” (no new base concept), but introduce `pending` status for the onboarding lifecycle. +- Replace legacy onboarding/registration routes (`/admin/register-tenant`, redirects under `/admin/managed-tenants/*`) with a single workspace-scoped onboarding wizard. +- Use existing provider verification operation type (`provider.connection.check`) executed via `ProviderConnectionHealthCheckJob` with `OperationRun` tracking. + +## Phase 1 — Design & Contracts (complete) + +Outputs: + +- `data-model.md`: entities, fields, relationships, validation, state transitions. +- `contracts/*`: documented HTTP routes + action contracts (OpenAPI-style where applicable). +- `quickstart.md`: dev notes, env vars, how to run tests. + +Design highlights: + +- Data model + - Tenants: change status lifecycle to include `pending`, ensure `workspace_id` is NOT NULL + FK, and enforce global uniqueness of `tenant_id` (Entra tenant ID) bound to exactly one workspace. + - Onboarding sessions: new table/model for resumable state (strictly non-secret) keyed by `(workspace_id, tenant_id)`. +- Authorization + - Introduce a workspace capability for onboarding (e.g., `workspace_managed_tenant.onboard`) and map it to Owner+Manager via `WorkspaceRoleCapabilityMap`. + - Enforce server-side authorization for every mutation and operation-start; 404 for non-members and cross-workspace access; 403 for members missing capability. +- Runs + - Verification is a queued `OperationRun` using `provider.connection.check`. + - Optional bootstrap actions become separate `OperationRun` types (only if they exist in the ProviderOperationRegistry). + +## Phase 2 — Implementation Plan (to be executed by /speckit.tasks) + +This plan intentionally stops before creating `tasks.md`. + +Proposed sequencing for tasks: + +1) Introduce `TenantOnboardingSession` model + migration, and add workspace-scoped uniqueness for tenants. +2) Implement `ManagedTenantOnboardingWizard` page mounted at `/admin/w/{workspace}/managed-tenants/onboarding`. +3) Wire verification start to existing `ProviderConnectionHealthCheckJob` / `provider.connection.check` operation. +4) Remove/disable legacy entry points (`RegisterTenant`, redirect routes) and ensure “not found” behavior. +5) Add Pest feature tests for: 404 vs 403 semantics, idempotency, resumability, and sanitized run outcomes. diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/quickstart.md b/specs/073-unified-managed-tenant-onboarding-wizard/quickstart.md new file mode 100644 index 0000000..14a7b08 --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/quickstart.md @@ -0,0 +1,35 @@ +# Quickstart — Unified Managed Tenant Onboarding Wizard (073) + +## Local setup + +- Start containers: `vendor/bin/sail up -d` +- Install deps (if needed): `vendor/bin/sail composer install` and `vendor/bin/sail npm install` +- Run migrations: `vendor/bin/sail artisan migrate` +- Run frontend build/dev: + - `vendor/bin/sail npm run dev` (watch) + - or `vendor/bin/sail npm run build` + +## Using the wizard (expected flow) + +1) Sign in to `/admin`. +2) Choose a workspace at `/admin/choose-workspace`. +3) Open `/admin/w/{workspace}/managed-tenants`. +4) Start onboarding at `/admin/w/{workspace}/managed-tenants/onboarding`. +5) Complete Identify → Connection → Verify (queued) → optional Bootstrap. + +Notes: + +- The onboarding UI must render DB-only; Graph calls occur only in queued work. +- Verification is tracked as an `OperationRun` (module `health_check`). + +## Tests + +Run targeted tests (expected file name when implemented): + +- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php` + +## Deploy / Ops + +If Filament assets are used/registered, deployment must include: + +- `php artisan filament:assets` diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/research.md b/specs/073-unified-managed-tenant-onboarding-wizard/research.md new file mode 100644 index 0000000..570acbd --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/research.md @@ -0,0 +1,62 @@ +# Research — Unified Managed Tenant Onboarding Wizard (073) + +This document resolves planning unknowns and records key implementation decisions. + +## Decisions + +### 1) Managed Tenant model = existing `Tenant` + +- **Decision:** Treat the existing `App\Models\Tenant` as the “Managed Tenant” concept. +- **Rationale:** The admin panel tenancy, membership model, and most operational flows already key off `Tenant`. +- **Alternatives considered:** + - Introduce a new `ManagedTenant` model/table. + - Keep `Tenant` as-is and build onboarding as “just another page”. +- **Why rejected:** A second tenant-like model would duplicate authorization, routing, and operational conventions. + +### 2) Workspace-scoped uniqueness + stable route key + +- **Decision:** Enforce uniqueness by `(workspace_id, tenant_id)` (where `tenant_id` is the Entra tenant ID), and ensure Filament’s route tenant key stays globally unique. +- **Rationale:** The feature spec explicitly defines the uniqueness key, and cross-workspace safety requires first-class scoping. +- **Implementation note:** Today `tenants.external_id` is unique and is force-set to `tenant_id` in `Tenant::saving()`. If we allow the same `tenant_id` across workspaces, `external_id` must NOT be set to `tenant_id` anymore. Prefer a generated opaque stable `external_id` (UUID) and keep `tenant_id` strictly as the business identifier. +- **Alternatives considered:** + - Keep global uniqueness on `tenant_id` and keep using `external_id = tenant_id`. +- **Why rejected:** Conflicts with the clarified uniqueness key and complicates “deny-as-not-found” behavior via DB constraint errors. + +### 3) Wizard route location = workspace-scoped (`/admin/w/{workspace}/...`) + +- **Decision:** Mount onboarding at a workspace-scoped route: `/admin/w/{workspace}/managed-tenants/onboarding`. +- **Rationale:** This path is explicitly exempted from forced tenant selection in `EnsureFilamentTenantSelected`, allowing onboarding before a tenant exists. +- **Alternatives considered:** + - Tenant-scoped Filament routes (`/admin/t/{tenant}/...`). + - Reusing Filament’s built-in tenant registration page (`tenantRegistration`). +- **Why rejected:** Tenant-scoped routes require a tenant to exist/selected; built-in registration is a legacy entry point we must remove. + +### 4) Verification implementation = existing provider operation (`provider.connection.check`) + +- **Decision:** Use `provider.connection.check` (module `health_check`) executed via `ProviderConnectionHealthCheckJob` as the onboarding verification run. +- **Rationale:** It already uses `OperationRun`, writes sanitized outcomes, and performs Graph calls off-request. +- **Alternatives considered:** + - New onboarding-specific operation type. +- **Why rejected:** Adds duplication without a clear benefit for v1. + +### 5) Authorization surface = workspace capability (Owner+Manager) + +- **Decision:** Add a dedicated workspace capability for onboarding (e.g., `workspace_managed_tenant.onboard`) and grant it to workspace Owner and Manager in `WorkspaceRoleCapabilityMap`. +- **Rationale:** The spec requires Owner+Manager; existing workspace capabilities don’t exactly match this (e.g., `WORKSPACE_MANAGE` is Owner-only). +- **Alternatives considered:** + - Check workspace role strings (`owner/manager`) directly. + - Reuse an unrelated capability like `WORKSPACE_MEMBERSHIP_MANAGE`. +- **Why rejected:** Constitution forbids role-string checks in feature code; reusing unrelated capability broadens authorization implicitly. + +### 6) Legacy entry points = removed/404 (no redirects) + +- **Decision:** Remove/disable these entry points and ensure 404 behavior: + - `/admin/register-tenant` (Filament registration page) + - `/admin/managed-tenants*` legacy redirects + - `/admin/new` redirect + - `/admin/w/{workspace}/managed-tenants/onboarding` redirect stub +- **Rationale:** FR-001 requires wizard-only entry and “not found” behavior. + +## Open Questions + +- None. All technical unknowns required for planning are resolved. diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/spec.md b/specs/073-unified-managed-tenant-onboarding-wizard/spec.md new file mode 100644 index 0000000..b7f9f25 --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/spec.md @@ -0,0 +1,185 @@ +# Feature Specification: Unified Managed Tenant Onboarding Wizard (073) + +**Feature Branch**: `073-unified-managed-tenant-onboarding-wizard` +**Created**: 2026-02-03 +**Status**: Draft +**Input**: User description: "Single, unified onboarding wizard for Managed Tenants (create/attach connection, verify, optional bootstrap), removing all legacy entry points." + +## Clarifications + +### Session 2026-02-03 + +- Q: Which workspace roles can start the onboarding wizard? → A: Only `owner` and `manager`. +- Q: If Provider Connections already exist, what should Step 2 do? → A: Auto-use the existing default connection (and allow switching). +- Q: What is the canonical uniqueness key for a Managed Tenant? → A: Unique globally by `tenant_id` (Entra tenant ID) and bound to exactly one workspace. +- Q: Which Managed Tenant status values exist in v1? → A: `pending`, `active`, `archived`. +- Q: Who can resume an existing onboarding session? → A: Any workspace `owner/manager` with the onboarding capability (shared session per tenant). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Start Managed Tenant onboarding (Priority: P1) + +As a workspace member with the required capability, I can start a single guided onboarding flow that creates (or resumes) a Managed Tenant in the current workspace, so that the tenant is always created consistently and safely. + +**Why this priority**: This is the primary entry point and eliminates inconsistent/unsafe creation paths. + +**Independent Test**: Can be fully tested by starting the onboarding in an empty workspace, completing step 1, and confirming a single Managed Tenant exists and is bound to that workspace. + +**Acceptance Scenarios**: + +1. **Given** a user has selected a workspace and has permission to onboard tenants, **When** they complete “Identify Managed Tenant”, **Then** exactly one Managed Tenant record exists for that workspace and tenant identifier. +2. **Given** a user repeats the same step with the same tenant identifier, **When** they submit again, **Then** no duplicate Managed Tenant is created and the existing onboarding session is continued. + +--- + +### User Story 2 - Configure a connection and verify access (Priority: P2) + +As a workspace member with the required capability, I can configure (or attach) a Provider Connection for the Managed Tenant and trigger a verification run, so that connectivity and permissions are validated without exposing secrets. + +**Why this priority**: Without a validated connection, the tenant cannot be safely managed. + +**Independent Test**: Can be tested by completing the “Connection” step and starting a verification run, then asserting the run is created with the expected scope and that no secrets appear in run outputs. + +**Acceptance Scenarios**: + +1. **Given** a Managed Tenant exists in the current workspace, **When** a user configures a connection, **Then** the system stores the connection as configured without ever showing stored secret material back to the user. +2. **Given** a user confirms they granted consent, **When** they trigger verification, **Then** a background verification run is started and is visible as “queued / running / succeeded / failed” with a sanitized outcome. + +--- + +### User Story 3 - Resume and complete onboarding (Priority: P3) + +As a workspace member, I can resume an incomplete onboarding session and complete optional bootstrap actions, so that interrupted onboarding does not create duplicates and finishes in a “ready” state. + +**Why this priority**: Real onboarding often pauses for consent/approvals; resumability reduces rework and errors. + +**Independent Test**: Can be tested by starting onboarding, leaving it incomplete, resuming, and finishing; then verifying the tenant is “ready” and optional actions create separate runs. + +**Acceptance Scenarios**: + +1. **Given** onboarding was started but not completed, **When** the user returns later, **Then** they can resume at the correct step with previously entered (non-secret) state. +2. **Given** verification succeeded, **When** the user chooses optional bootstrap actions, **Then** each selected action starts its own background run and onboarding can still be completed. + +--- + +### Edge Cases + +- Cross-workspace isolation: a tenant identifier that exists in a different workspace must not be attachable or discoverable (deny-as-not-found). +- Missing capability: members without the required capability see disabled UI affordances, and server-side requests are denied. +- Roles and capabilities: `operator` and `readonly` members cannot start onboarding by default. +- Resume permissions: onboarding can be resumed by any authorized workspace `owner/manager` (not only the initiator). +- Verification failures: outcomes must be actionable (reason code + safe message) and never leak tokens/secrets. +- Idempotency: repeated submissions or refreshes must not create duplicate tenants, duplicate default connections, or a runaway number of active verification runs. +- Last-owner protections: demoting/removing the last owner (workspace or managed tenant) is blocked and recorded for audit. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: +- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`), +- ensure any cross-plane access is deny-as-not-found (404), +- explicitly define 404 vs 403 semantics: + - non-member / not entitled to tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403 +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + +### Scope & Assumptions + +**In scope (v1)** + +- A single onboarding wizard to create or resume onboarding of a Managed Tenant within a selected workspace. +- Configure or attach a Provider Connection, guide consent, start verification runs, and optionally start bootstrap runs. +- Completion marks the tenant as ready/active and routes the user to the tenant details. +- Removal of all legacy UI entry points for creating/onboarding tenants (no redirects). + +**Out of scope (v1)** + +- User invitation workflows. +- Group-based auto-provisioning. +- Full compliance/evidence reporting. +- Cloud resource provisioning. + +**Dependencies** + +- Workspace selection/context and workspace membership. +- A managed-tenant concept bound to exactly one workspace. +- Provider Connections and secure credential storage. +- A run system to track verification and bootstrap actions. +- Audit logging and a canonical capability registry. + +**Assumptions** + +- Default policy: the onboarding initiator becomes workspace manager and managed-tenant owner (or the closest minimum-privilege equivalents). +- “Not found” behavior is used to avoid leaking the existence of out-of-scope tenants. + +### Acceptance Coverage + +The following acceptance coverage is required to treat the feature as complete: + +- Legacy entry points removed (not found behavior). +- Workspace isolation enforced (cross-workspace attach/visibility prevented). +- Idempotency verified (no duplicates created by repeated submissions). +- Verification run creation and sanitized failure reporting. +- Last-owner protections enforced and auditable. + +### Functional Requirements + +- **FR-001 (Single entry point)**: System MUST provide exactly one UI flow to onboard a Managed Tenant (the onboarding wizard), and all other “add tenant” entry points MUST be removed and behave as “not found”. +- **FR-002 (Workspace-first enforcement)**: System MUST require an active workspace context for onboarding and tenant-scoped access. +- **FR-003 (Hard isolation)**: System MUST deny-as-not-found (404 semantics) when a Managed Tenant does not belong to the current workspace, including for attempts to attach an existing tenant identifier from another workspace. +- **FR-004 (Authorization semantics)**: System MUST enforce authorization server-side for all onboarding mutations and run-start actions. Non-member / not entitled to tenant scope MUST be treated as 404 semantics; a member lacking the required capability MUST be treated as 403 semantics. By default, only workspace `owner` and `manager` can start the onboarding wizard. +- **FR-005 (Capabilities-first)**: System MUST authorize via canonical capabilities (not role string comparisons in feature code). +- **FR-006 (Idempotent tenant identification)**: System MUST upsert tenant identification by a stable tenant identifier within the workspace, so repeating step 1 never creates duplicates. +- **FR-006a (Tenant uniqueness key)**: System MUST enforce a single Managed Tenant globally per `tenant_id` (Entra tenant ID) and bind it to exactly one workspace. +- **FR-007 (Onboarding session resumability)**: System MUST persist onboarding state (excluding secret material) so the flow can be resumed after interruption without data inconsistency. +- **FR-007a (Shared resumability)**: An onboarding session MUST be resumable by any authorized workspace `owner/manager` with the onboarding capability (not only the user who started it). +- **FR-008 (Connection handling)**: System MUST allow creating or attaching a Provider Connection during onboarding and MUST never display stored secret material back to users; UI MUST only show safe configuration indicators (e.g., configured yes/no, last rotation timestamp). +- **FR-008a (Default connection selection)**: If one or more Provider Connections already exist for the Managed Tenant, Step 2 MUST auto-select the default connection and MAY allow the user to switch to a different existing connection. +- **FR-009 (Verification as runs)**: System MUST start verification as a background run with clear status and a sanitized result (reason code + short safe message). +- **FR-010 (DB-only UI rendering)**: System MUST render onboarding UI using only stored data; any external calls required for verification MUST occur only in background work. +- **FR-011 (Operational clarity)**: System MUST display verification outcomes and missing requirements in a user-actionable way (what is missing, what to do next) without leaking sensitive details. +- **FR-012 (Optional bootstrap actions)**: System MUST support optional post-verify bootstrap actions that each start their own background run and do not block completion unless explicitly selected. +- **FR-013 (Completion state)**: System MUST mark the Managed Tenant as ready/active only after successful verification, and MUST redirect users to the Managed Tenant details view upon completion. +- **FR-013a (Status model)**: System MUST use a v1 Managed Tenant lifecycle with statuses: `pending` (created/onboarding), `active` (ready), `archived` (no longer managed). +- **FR-014 (Membership bootstrap)**: System MUST ensure the onboarding initiator receives the minimum required memberships in the workspace and the managed tenant scope according to policy (default: workspace manager + tenant owner). +- **FR-015 (Last-owner protections)**: System MUST block demotion/removal of the last owner at both workspace scope and managed tenant scope, and MUST record the blocked attempt for audit. +- **FR-016 (Auditability)**: System MUST record audit events for tenant creation, connection creation/rotation, verification start/result, membership changes, and last-owner blocks. + +### Key Entities *(include if feature involves data)* + +- **Workspace**: A portfolio/customer context that owns memberships and one or more Managed Tenants. +- **Managed Tenant**: A managed Entra/Intune tenant, uniquely identified within a workspace by an external tenant identifier, with lifecycle status (e.g., pending/ready/archived). +- Uniqueness: exactly one globally per `tenant_id` (Entra tenant ID), bound to exactly one workspace. +- Status values (v1): `pending`, `active`, `archived`. +- **Provider Connection**: A technical connection configuration that enables access to a Managed Tenant; includes secure credentials/configuration metadata and enabled/default flags. +- **Onboarding Session**: A persistent record of onboarding progress and safe state to support resumability and idempotency. +- **Verification Run**: A background run that validates connectivity and required permissions and produces a sanitized outcome. +- **Membership (Workspace-scoped / Tenant-scoped)**: Defines who can see and operate within a workspace and on a specific managed tenant. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (Time-to-onboard)**: A workspace admin can complete the wizard up to starting verification in under 3 minutes (excluding external consent/approval waiting time). +- **SC-002 (Idempotency)**: Re-running any wizard step does not create duplicates (0 duplicate tenants per tenant identifier per workspace; 0 duplicate default connections per tenant). +- **SC-003 (Authorization correctness)**: For all onboarding endpoints/actions, non-members see no discoverability and get 404 semantics; members without capability get 403 semantics; authorized users can complete the flow. +- **SC-004 (Secret safety)**: No secrets/tokens are present in run outputs, notifications, audit entries, or error messages (validated by automated tests that assert redaction/sanitization behavior). +- **SC-005 (Operational clarity)**: When verification fails, users can identify the failure reason category (via reason code + safe message) and see the next step without contacting support. + +### Badge Semantics (BADGE-001) + +- Managed Tenant status badges MUST map from the canonical status set (`pending`, `active`, `archived`) using a centralized mapping (no ad-hoc per-page mapping). diff --git a/specs/073-unified-managed-tenant-onboarding-wizard/tasks.md b/specs/073-unified-managed-tenant-onboarding-wizard/tasks.md new file mode 100644 index 0000000..d49fa9b --- /dev/null +++ b/specs/073-unified-managed-tenant-onboarding-wizard/tasks.md @@ -0,0 +1,159 @@ +--- + +description: "Tasks for Unified Managed Tenant Onboarding Wizard (073)" +--- + +# Tasks: Unified Managed Tenant Onboarding Wizard (073) + +**Input**: Design documents from `specs/073-unified-managed-tenant-onboarding-wizard/` + +**Tests**: Required (Pest). Use `vendor/bin/sail artisan test --compact ...`. + +## Phase 1: Setup + +- [X] T001 Confirm Sail is running and DB is reachable using docker-compose.yml (command: `vendor/bin/sail up -d`) +- [X] T002 Confirm baseline tests pass for the branch using phpunit.xml and tests/ (command: `vendor/bin/sail artisan test --compact`) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives required by all user stories (authz, data model, safety semantics). + +- [X] T003 Add onboarding capability constant in app/Support/Auth/Capabilities.php +- [X] T004 Add onboarding capability mapping for Owner+Manager in app/Services/Auth/WorkspaceRoleCapabilityMap.php +- [X] T005 Implement Gate/Policy for onboarding authorization in app/Providers/AuthServiceProvider.php (enforce capabilities; no role-string checks) +- [X] T006 [P] Create TenantOnboardingSession model in app/Models/TenantOnboardingSession.php +- [X] T007 Create onboarding sessions migration in database/migrations/*_create_tenant_onboarding_sessions_table.php (unique workspace_id + tenant_id) +- [X] T008 Create tenant workspace binding migration in database/migrations/*_enforce_tenant_workspace_binding.php (ensure tenants.workspace_id is NOT NULL + FK; ensure tenants.tenant_id remains globally unique; deny cross-workspace duplicates) +- [X] T009 Verify tenant routing key strategy for v1: keep existing Filament tenant route-key stable (do NOT change external_id strategy in this feature); add a regression test that /admin/t/{tenant} continues to resolve the intended managed tenant +- [X] T010 [P] Add foundational authorization + data-model tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (capability known, mapping correct, migrations applied) + +**Checkpoint**: Foundational complete — user story work can begin. + +--- + +## Phase 3: User Story 1 — Start Managed Tenant onboarding (Priority: P1) 🎯 MVP + +**Goal**: Start or resume a workspace-scoped onboarding wizard and create exactly one Managed Tenant per global-unique `tenant_id` (Entra tenant ID), bound to exactly one workspace. + +**Independent Test**: Start onboarding in an empty workspace and complete “Identify Managed Tenant”; assert exactly one tenant exists and a session is created/resumed. + +- [X] T011 [P] [US1] Add wizard page class in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Filament v5 / Livewire v4) +- [X] T012 [P] [US1] Add wizard view in resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php +- [X] T013 [US1] Register wizard route in routes/web.php at `/admin/w/{workspace}/managed-tenants/onboarding` with `ensure-workspace-member` middleware and 404 semantics for non-members +- [X] T014 [US1] Implement wizard mount + workspace loading in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (abort 404 for non-member, 403 for missing onboarding capability) +- [X] T015 [US1] Implement Step 1 “Identify Managed Tenant” upsert in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (transactional; idempotent by workspace_id + tenant_id; tenant status `pending`) +- [X] T015b [US1] Enforce cross-workspace uniqueness in Step 1: if a tenant with the same tenant_id exists in a different workspace, deny-as-not-found (404) and do not create/update anything +- [X] T015c [US1] Membership bootstrap: after tenant upsert, ensure the initiating user has a Managed Tenant membership of role owner (create if missing); never allow tenant to end up with zero owners +- [X] T016 [US1] Persist/resume onboarding session in app/Models/TenantOnboardingSession.php (no secrets in state) +- [X] T017 [US1] Add audit events for onboarding start/resume in app/Services/Audit/WorkspaceAuditLogger.php (or existing audit service) and call from wizard actions +- [X] T018 [P] [US1] Add happy-path tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (owner/manager can start; tenant created; session created) +- [X] T019 [P] [US1] Add negative auth tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (non-member gets 404; member without capability gets 403) +- [X] T020 [P] [US1] Add idempotency tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (repeat step does not create duplicates) +- [X] T020b [P] [US1] Add tests asserting membership bootstrap: newly created tenant has exactly one owner membership for the initiator; attempting to remove/demote the last owner is blocked (can be a minimal service/policy-level assertion) +- [X] T020c [P] [US1] Add tests asserting cross-workspace protection: if tenant_id exists under another workspace, the wizard returns 404 and does not reveal the existence of that tenant + +### Remove legacy entry points (required by FR-001) + +- [X] T021 [US1] Remove tenant registration from app/Providers/Filament/AdminPanelProvider.php (drop `->tenantRegistration(...)`) +- [X] T022 [US1] Remove `/admin/register-tenant` route from routes/web.php (must behave as not found) +- [X] T023 [US1] Replace legacy onboarding redirects with 404 in routes/web.php (`/admin/managed-tenants`, `/admin/managed-tenants/onboarding`, `/admin/new`, workspace onboarding redirect stub) +- [X] T024 [US1] Remove RegisterTenant references in app/Filament/Pages/ChooseTenant.php and app/Filament/Pages/Workspaces/ManagedTenantsLanding.php +- [X] T025 [P] [US1] Add regression tests in tests/Feature/ManagedTenantOnboardingWizardTest.php asserting legacy endpoints return 404 (no redirects) + + +**Checkpoint**: US1 complete — wizard is the only entry point; onboarding start is safe + idempotent. + +--- + +## Phase 4: User Story 2 — Configure a connection and verify access (Priority: P2) + +**Goal**: Attach or create a Provider Connection and start verification as an `OperationRun` without leaking secrets. + +**Independent Test**: Select/create connection, start verification, assert an OperationRun is created and job is dispatched; assert no secret material is returned. + +- [X] T026 [US2] Implement Step 2 connection selection in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (auto-select default connection; allow switching) +- [X] T027 [US2] Implement connection creation path in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php using app/Models/ProviderConnection.php and app/Services/Providers/CredentialManager.php (never display stored secrets) +- [X] T028 [US2] Persist selected connection id in app/Models/TenantOnboardingSession.php `state` (non-secret) +- [X] T029 [US2] Implement “Start verification” action using app/Services/Providers/ProviderOperationStartGate.php with operation type `provider.connection.check` +- [X] T029b [US2] Enforce/verify dedupe: clicking “Start verification” twice while an active run exists must return the active OperationRun (no second run created); add a focused test (Bus::fake + assert single run) +- [X] T030 [US2] Ensure verification enqueues app/Jobs/ProviderConnectionHealthCheckJob.php and stores `operation_run_id` in onboarding session state +- [X] T031 [US2] Add “View run” navigation to app/Filament/Resources/OperationRunResource.php (link from wizard action notification) +- [X] T032 [P] [US2] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for connection default selection + switching +- [X] T033 [P] [US2] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for verification run creation + job dispatch (Bus::fake) +- [X] T034 [P] [US2] Add secret-safety tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (no secret fields appear in response/session/run failure summary) + +**Checkpoint**: US2 complete — verification is observable via OperationRun and secrets are safe. + +--- + +## Phase 5: User Story 3 — Resume and complete onboarding (Priority: P3) + +**Goal**: Resume an onboarding session, run optional bootstrap actions, and complete onboarding to activate the tenant. + +**Independent Test**: Start onboarding, leave incomplete, resume as a different authorized owner/manager, complete verification + bootstrap, then mark tenant active. + +- [X] T035 [US3] Implement session resume logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (load by workspace_id + tenant_id; shared resumability) +- [X] T036 [US3] Implement Step gating in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (cannot complete until verification succeeded) +- [X] T037 [US3] Implement optional bootstrap actions in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start operations listed in app/Services/Providers/ProviderOperationRegistry.php) +- [X] T038 [US3] Persist bootstrap `operation_run_id`s in app/Models/TenantOnboardingSession.php `state` +- [X] T039 [US3] Implement completion: set tenant status `active`, set onboarding session `completed_at`, redirect to tenant dashboard (app/Filament/Pages/TenantDashboard.php) +- [X] T040 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for resume by different authorized actor +- [X] T041 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for completion and tenant status transition `pending` → `active` +- [X] T042 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for bootstrap run creation (one OperationRun per selected action) + +**Checkpoint**: US3 complete — onboarding is resumable and completes safely. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T043 Add Managed Tenant status badge mapping via BadgeCatalog/BadgeRenderer in app/Support/Badges/* (BADGE-001) and add mapping test in tests/Feature/Badges/TenantStatusBadgeTest.php +- [X] T044 Verify/extend audit coverage for FR-016: use stable audit action IDs (enum/registry), ensure redaction, and add at least one concrete feature test asserting audit rows for onboarding start + verification start (no secrets in payload) +- [X] T045 Verify last-owner protections cover both workspace + tenant memberships; extend policies if needed in app/Policies/* and add regression tests in tests/Feature/Rbac/* +- [X] T046 Run formatter on touched files (command: `vendor/bin/sail bin pint --dirty`) +- [X] T047 Run targeted test suite for onboarding (command: `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`) + +### Post-spec hardening (Filament-native UX) + +- [X] T048 Refactor onboarding page to a Filament-native Wizard schema (replace header-action modals + step cards; persist per-step progress; keep strict RBAC and existing action methods) +- [X] T049 Fix tenant identify UX: entering an existing tenant GUID must not surface a raw 404 modal; bind legacy unscoped tenants to the current workspace when safely inferable and add a regression test + + +--- + +## Dependencies & Execution Order + +### User Story completion order + +1. US1 (P1) depends on Phase 2 only. +2. US2 (P2) depends on US1 (tenant/session + wizard scaffold). +3. US3 (P3) depends on US2 (verification state + run linking). + +### Dependency graph + +- Phase 1 → Phase 2 → US1 → US2 → US3 → Polish + +--- + +## Parallel execution examples + +### US1 parallel work + +- [P] T011 and T012 can be implemented in parallel (page class vs blade view). +- [P] T018–T020 can be written in parallel (distinct test cases). + +### US2 parallel work + +- [P] T032–T034 can be written in parallel (selection tests vs run tests vs secret-safety tests). + +### US3 parallel work + +- [P] T040–T042 can be written in parallel (resume tests vs completion tests vs bootstrap tests). + +--- + +## Implementation Strategy (MVP) + +- MVP scope is US1 only: wizard-only entry point + idempotent tenant identification + resumable session skeleton + required authorization semantics + tests. diff --git a/tests/Feature/AdminNewRedirectTest.php b/tests/Feature/AdminNewRedirectTest.php index e278284..2dd636c 100644 --- a/tests/Feature/AdminNewRedirectTest.php +++ b/tests/Feature/AdminNewRedirectTest.php @@ -4,5 +4,5 @@ it('redirects /admin/new to /admin/login for guests', function (): void { $this->get('/admin/new') - ->assertRedirect('/admin/login'); + ->assertNotFound(); }); diff --git a/tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php b/tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php new file mode 100644 index 0000000..65ff69d --- /dev/null +++ b/tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php @@ -0,0 +1,46 @@ +create(); + $actor = User::factory()->create(); + + /** @var WorkspaceAuditLogger $logger */ + $logger = app(WorkspaceAuditLogger::class); + + $logger->log( + workspace: $workspace, + action: 'test.redaction', + context: [ + 'metadata' => [ + 'access_token' => 'super-secret-token', + 'client_secret' => 'super-secret-secret', + 'nested' => [ + 'Authorization' => 'Bearer abc.def.ghi', + 'safe' => 'ok', + ], + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + $log = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', 'test.redaction') + ->latest('id') + ->firstOrFail(); + + expect($log->metadata['access_token'] ?? null)->toBe('[REDACTED]'); + expect($log->metadata['client_secret'] ?? null)->toBe('[REDACTED]'); + expect($log->metadata['nested']['Authorization'] ?? null)->toBe('[REDACTED]'); + expect($log->metadata['nested']['safe'] ?? null)->toBe('ok'); +}); diff --git a/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php b/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php index 0679983..1950063 100644 --- a/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php +++ b/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php @@ -3,6 +3,9 @@ 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; @@ -14,7 +17,18 @@ $this->get('/admin/login')->assertOk(); $user = User::factory()->create(); - $this->actingAs($user); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]); $this->get('/admin/no-access')->assertOk(); $this->get('/admin/choose-tenant')->assertOk(); diff --git a/tests/Feature/Auth/SessionSeparationSmokeTest.php b/tests/Feature/Auth/SessionSeparationSmokeTest.php index eb8a74d..60287e3 100644 --- a/tests/Feature/Auth/SessionSeparationSmokeTest.php +++ b/tests/Feature/Auth/SessionSeparationSmokeTest.php @@ -4,32 +4,39 @@ use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; -use App\Models\TenantMembership; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; +use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); it('does not allow a non-member user to access tenant-scoped admin routes', function () { - $tenant = Tenant::factory()->create(['status' => 'active']); + [$member, $tenant] = createUserWithTenant( + tenant: Tenant::factory()->create(['status' => 'active']), + user: User::factory()->create(), + role: 'owner', + ); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail(); - $member = User::factory()->create(); $nonMember = User::factory()->create(); - - TenantMembership::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $member->getKey(), + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $nonMember->getKey(), 'role' => 'owner', - 'source' => 'manual', - 'source_ref' => null, - 'created_by_user_id' => null, ]); - $this->actingAs($nonMember); - $this->get(TenantDashboard::getUrl(tenant: $tenant))->assertNotFound(); + $this->actingAs($nonMember) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertNotFound(); - $this->actingAs($member); - $this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful(); + $this->actingAs($member) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertSuccessful(); $this->get('/system')->assertNotFound(); }); diff --git a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php index f7c6726..3f4e763 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php @@ -10,7 +10,9 @@ test('backup schedules listing is tenant scoped', function () { [$user, $tenantA] = createUserWithTenant(role: 'manager'); - $tenantB = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $tenantA->workspace_id, + ]); createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); @@ -46,6 +48,10 @@ $this->actingAs($user); + // createUserWithTenant() may be called multiple times in this test; ensure the current + // workspace matches the tenant we are about to access. + session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA))) ->assertOk() ->assertSee('Tenant A schedule') diff --git a/tests/Feature/Badges/TenantStatusBadgeTest.php b/tests/Feature/Badges/TenantStatusBadgeTest.php new file mode 100644 index 0000000..f28396a --- /dev/null +++ b/tests/Feature/Badges/TenantStatusBadgeTest.php @@ -0,0 +1,18 @@ +label)->toBe('Pending'); + expect($spec->color)->toBe('warning'); + expect($spec->icon)->toBe('heroicon-m-clock'); +}); + +it('normalizes tenant status input before mapping', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'PENDING'); + + expect($spec->label)->toBe('Pending'); +}); diff --git a/tests/Feature/DirectoryGroups/BrowseGroupsTest.php b/tests/Feature/DirectoryGroups/BrowseGroupsTest.php index d31cba6..f1082a7 100644 --- a/tests/Feature/DirectoryGroups/BrowseGroupsTest.php +++ b/tests/Feature/DirectoryGroups/BrowseGroupsTest.php @@ -90,7 +90,9 @@ test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () { $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $tenantA->workspace_id, + ]); $groupB = EntraGroup::query()->create([ 'tenant_id' => $tenantB->getKey(), @@ -103,10 +105,7 @@ ]); $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenantA->getKey() => ['role' => 'owner'], - $tenantB->getKey() => ['role' => 'owner'], - ]); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner'); $this->actingAs($user) ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA)) diff --git a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php index dc67eb9..82141b2 100644 --- a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php +++ b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php @@ -18,9 +18,7 @@ ]); $this->user = User::factory()->create(); - $this->user->tenants()->syncWithoutDetaching([ - $this->tenant->getKey() => ['role' => 'owner'], - ]); + [$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner'); }); it('renders policy version view without any Graph calls during render', function () { diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php index 87aecdb..23bef9d 100644 --- a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -28,7 +28,7 @@ ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin') - ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); + ->assertRedirect(route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()])); }); it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void { diff --git a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php index 8f00a23..1bcd99d 100644 --- a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php @@ -4,34 +4,30 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('policy detail shows app protection settings in readable sections', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', + $tenant = Tenant::factory()->create([ 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); - $tenant->makeCurrent(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - $policy = Policy::create([ - 'tenant_id' => $tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), 'external_id' => 'policy-1', 'policy_type' => 'appProtectionPolicy', 'display_name' => 'Teams', 'platform' => 'mobile', ]); - PolicyVersion::create([ - 'tenant_id' => $tenant->id, - 'policy_id' => $policy->id, + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, @@ -46,11 +42,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php index df632bf..e259f06 100644 --- a/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php +++ b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php @@ -29,7 +29,7 @@ ->assertDontSee('Register tenant'); }); -it('shows the register-tenant CTA for owner workspace members when there are no tenants', function (): void { +it('does not show the register-tenant CTA for owner workspace members when there are no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -44,5 +44,7 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin/choose-tenant') ->assertSuccessful() - ->assertSee('Register tenant'); + ->assertSee('No tenants are available') + ->assertSee('Change workspace') + ->assertDontSee('Register tenant'); }); diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php index 0ed7cc6..06724b3 100644 --- a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -3,42 +3,30 @@ use App\Filament\Resources\PolicyResource; use App\Models\Policy; use App\Models\PolicyVersion; -use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', - 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, - ]); - - $tenant->makeCurrent(); + [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->tenant = $tenant; - $this->user = User::factory()->create(); - $this->user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + $this->user = $user; }); test('policy detail renders normalized settings for Autopilot profiles', function () { - $policy = Policy::create([ - 'tenant_id' => $this->tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->getKey(), 'external_id' => 'autopilot-1', 'policy_type' => 'windowsAutopilotDeploymentProfile', 'display_name' => 'Autopilot Profile A', 'platform' => 'windows', ]); - PolicyVersion::create([ - 'tenant_id' => $this->tenant->id, - 'policy_id' => $policy->id, + PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, @@ -71,17 +59,17 @@ }); test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () { - $policy = Policy::create([ - 'tenant_id' => $this->tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->getKey(), 'external_id' => 'esp-1', 'policy_type' => 'windowsEnrollmentStatusPage', 'display_name' => 'ESP A', 'platform' => 'windows', ]); - PolicyVersion::create([ - 'tenant_id' => $this->tenant->id, - 'policy_id' => $policy->id, + PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, @@ -113,17 +101,17 @@ }); test('policy detail renders normalized settings for platform restrictions (enrollment)', function () { - $policy = Policy::create([ - 'tenant_id' => $this->tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->getKey(), 'external_id' => 'enroll-restrict-1', 'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'display_name' => 'Restriction A', 'platform' => 'all', ]); - PolicyVersion::create([ - 'tenant_id' => $this->tenant->id, - 'policy_id' => $policy->id, + PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php index b0737c2..92a271c 100644 --- a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php +++ b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php @@ -22,6 +22,8 @@ test('entra group sync runs are listed for the active tenant', function () { $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $otherTenant = Tenant::factory()->create(); EntraGroupSyncRun::query()->create([ @@ -38,12 +40,6 @@ 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - $otherTenant->getKey() => ['role' => 'owner'], - ]); - $this->actingAs($user) ->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant)) ->assertOk() @@ -53,7 +49,9 @@ test('entra group sync run view is forbidden cross-tenant (403)', function () { $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $tenantA->workspace_id, + ]); $runB = EntraGroupSyncRun::query()->create([ 'tenant_id' => $tenantB->getKey(), @@ -63,10 +61,7 @@ ]); $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenantA->getKey() => ['role' => 'owner'], - $tenantB->getKey() => ['role' => 'owner'], - ]); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner'); $this->actingAs($user) ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) diff --git a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php index 3254355..dce8d9c 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php @@ -2,7 +2,6 @@ use App\Models\Policy; use App\Models\Tenant; -use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\BackupService; @@ -90,11 +89,10 @@ public function request(string $method, string $path, array $options = []): Grap $client = new GroupPolicyHydrationGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-gpo-hydration', 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); @@ -102,8 +100,8 @@ public function request(string $method, string $path, array $options = []): Grap $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; $tenant->makeCurrent(); - $policy = Policy::create([ - 'tenant_id' => $tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), 'external_id' => 'gpo-hydrate', 'policy_type' => 'groupPolicyConfiguration', 'display_name' => 'Admin Templates Alpha', @@ -132,10 +130,7 @@ public function request(string $method, string $path, array $options = []): Grap metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id], ); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $response = $this ->actingAs($user) diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 7c2bd74..923fd82 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -398,16 +398,14 @@ }); test('tenant can be archived and hidden from default lists', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-4', 'name' => 'Tenant 4', + 'status' => 'active', ]); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); Livewire::test(ListTenants::class) @@ -436,64 +434,86 @@ }); test('tenant table archive filter toggles active and archived tenants', function () { - $active = Tenant::create([ + $active = Tenant::factory()->create([ 'tenant_id' => 'tenant-active', 'name' => 'Active Tenant', + 'status' => 'active', ]); - $archived = Tenant::create([ + [$user, $active] = createUserWithTenant(tenant: $active, role: 'owner'); + $this->actingAs($user); + + $archived = Tenant::factory()->create([ 'tenant_id' => 'tenant-archived', 'name' => 'Archived Tenant', + 'status' => 'active', + 'workspace_id' => $active->workspace_id, ]); $archived->delete(); - $user = User::factory()->create(); - $this->actingAs($user); $user->tenants()->syncWithoutDetaching([ - $active->getKey() => ['role' => 'owner'], $archived->getKey() => ['role' => 'owner'], ]); Filament::setTenant($active, true); - $component = Livewire::test(ListTenants::class) + $this->withSession([ + \App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $active->workspace_id, + ]); + session([ + \App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $active->workspace_id, + ]); + + $component = Livewire::actingAs($user) + ->test(ListTenants::class) + ->filterTable('trashed', true) ->assertSee($active->name) ->assertSee($archived->name); $component - ->set('tableFilters.trashed.value', null) + ->filterTable('trashed', null) ->assertSee($active->name) ->assertDontSee($archived->name); $component - ->set('tableFilters.trashed.value', 0) + ->filterTable('trashed', false) ->assertSee($archived->name) ->assertDontSee($active->name); }); test('archived tenant can be restored from the table', function () { - $tenant = Tenant::create([ + $contextTenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-restore-context', + 'name' => 'Restore Context Tenant', + 'status' => 'active', + ]); + + [$user, $contextTenant] = createUserWithTenant(tenant: $contextTenant, role: 'owner'); + $this->actingAs($user); + + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-restore', 'name' => 'Restore Tenant', + 'status' => 'active', + 'workspace_id' => $contextTenant->workspace_id, ]); $tenant->delete(); - - $contextTenant = Tenant::create([ - 'tenant_id' => 'tenant-restore-context', - 'name' => 'Restore Context Tenant', - ]); - - $user = User::factory()->create(); - $this->actingAs($user); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], - $contextTenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($contextTenant, true); - Livewire::test(ListTenants::class) - ->set('tableFilters.trashed.value', 1) + $this->withSession([ + \App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $contextTenant->workspace_id, + ]); + session([ + \App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $contextTenant->workspace_id, + ]); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->filterTable('trashed', false) ->callTableAction('restore', $tenant); $this->assertDatabaseHas('tenants', [ diff --git a/tests/Feature/Filament/InventoryItemResourceTest.php b/tests/Feature/Filament/InventoryItemResourceTest.php index f46e5bd..2e386b7 100644 --- a/tests/Feature/Filament/InventoryItemResourceTest.php +++ b/tests/Feature/Filament/InventoryItemResourceTest.php @@ -4,7 +4,6 @@ use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Models\InventoryItem; use App\Models\Tenant; -use App\Models\User; use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Support\Facades\Http; @@ -18,6 +17,8 @@ test('inventory items are listed for the active tenant', function () { $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $otherTenant = Tenant::factory()->create(); InventoryItem::factory()->create([ @@ -36,12 +37,6 @@ 'platform' => 'windows', ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - $otherTenant->getKey() => ['role' => 'owner'], - ]); - $this->actingAs($user) ->get(InventoryItemResource::getUrl('index', tenant: $tenant)) ->assertOk() diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index b3869d8..21f87ba 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -6,17 +6,12 @@ use App\Models\InventoryItem; use App\Models\InventorySyncRun; use App\Models\Tenant; -use App\Models\User; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); test('inventory hub pages load for a tenant', function () { $tenant = Tenant::factory()->create(); - - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), diff --git a/tests/Feature/Filament/InventorySyncRunResourceTest.php b/tests/Feature/Filament/InventorySyncRunResourceTest.php index b17de43..4cccbfe 100644 --- a/tests/Feature/Filament/InventorySyncRunResourceTest.php +++ b/tests/Feature/Filament/InventorySyncRunResourceTest.php @@ -3,7 +3,6 @@ use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventorySyncRun; use App\Models\Tenant; -use App\Models\User; use Illuminate\Support\Facades\Http; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -14,6 +13,8 @@ test('inventory sync runs are listed for the active tenant', function () { $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $otherTenant = Tenant::factory()->create(); InventorySyncRun::factory()->create([ @@ -28,12 +29,6 @@ 'status' => InventorySyncRun::STATUS_SUCCESS, ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - $otherTenant->getKey() => ['role' => 'owner'], - ]); - $this->actingAs($user) ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) ->assertOk() diff --git a/tests/Feature/Filament/MalformedSnapshotWarningTest.php b/tests/Feature/Filament/MalformedSnapshotWarningTest.php index cd5abbb..314c7a8 100644 --- a/tests/Feature/Filament/MalformedSnapshotWarningTest.php +++ b/tests/Feature/Filament/MalformedSnapshotWarningTest.php @@ -5,20 +5,19 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('malformed snapshot renders warning on policy and version detail', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -40,11 +39,6 @@ 'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $policyResponse = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 78dd683..0f9b627 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -6,7 +6,6 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\RestoreService; @@ -49,13 +48,13 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon } }); - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -99,11 +98,6 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'payload' => $snapshot, ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $detailResponse = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 49c2e81..236a37d 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -30,8 +30,8 @@ ]); $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], $otherTenant->getKey() => ['role' => 'owner'], ]); diff --git a/tests/Feature/Filament/PolicySettingsDisplayTest.php b/tests/Feature/Filament/PolicySettingsDisplayTest.php index 1d2c934..4c6d17f 100644 --- a/tests/Feature/Filament/PolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/PolicySettingsDisplayTest.php @@ -11,14 +11,7 @@ uses(RefreshDatabase::class); test('policy detail shows normalized settings section', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', - 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, - ]); - - $tenant->makeCurrent(); + $tenant = Tenant::factory()->create(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -49,9 +42,7 @@ ]); $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php index dcd3999..010e84c 100644 --- a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -11,14 +11,7 @@ uses(RefreshDatabase::class); test('policy settings standard view renders array values without crashing', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-arrays', - 'name' => 'Tenant Arrays', - 'metadata' => [], - 'is_current' => true, - ]); - - $tenant->makeCurrent(); + $tenant = Tenant::factory()->create(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -48,9 +41,7 @@ ]); $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); diff --git a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php index 516bdc8..c6506e3 100644 --- a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php +++ b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php @@ -11,14 +11,7 @@ uses(RefreshDatabase::class); test('policy version detail renders tabs and scroll-safe blocks', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', - 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, - ]); - - $tenant->makeCurrent(); + $tenant = Tenant::factory()->create(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -58,9 +51,7 @@ ]); $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $response = $this->actingAs($user) ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); diff --git a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php index 2f96209..94ec7ed 100644 --- a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php +++ b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php @@ -4,33 +4,30 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('policy version view shows scope tags even when assignments are missing', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', + $tenant = Tenant::factory()->create([ 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); - $tenant->makeCurrent(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - $policy = Policy::create([ - 'tenant_id' => $tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), 'external_id' => 'policy-1', 'policy_type' => 'deviceConfiguration', 'display_name' => 'Policy A', 'platform' => 'windows', ]); - $version = PolicyVersion::create([ - 'tenant_id' => $tenant->id, - 'policy_id' => $policy->id, + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, @@ -46,11 +43,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index 79c9d71..f8cff84 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -4,33 +4,30 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); test('policy version detail shows raw and normalized settings', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', + $tenant = Tenant::factory()->create([ 'name' => 'Tenant One', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); - $tenant->makeCurrent(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - $policy = Policy::create([ - 'tenant_id' => $tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), 'external_id' => 'policy-1', 'policy_type' => 'deviceConfiguration', 'display_name' => 'Policy A', 'platform' => 'windows', ]); - $version = PolicyVersion::create([ - 'tenant_id' => $tenant->id, - 'policy_id' => $policy->id, + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, @@ -44,11 +41,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); @@ -61,26 +53,25 @@ }); test('policy version detail shows enrollment notification template settings', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-enrollment-notify', 'name' => 'Tenant Enrollment Notify', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); - $tenant->makeCurrent(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - $policy = Policy::create([ - 'tenant_id' => $tenant->id, + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), 'external_id' => 'enroll-notify-1', 'policy_type' => 'deviceEnrollmentNotificationConfiguration', 'display_name' => 'Enrollment Notifications', 'platform' => 'all', ]); - $version = PolicyVersion::create([ - 'tenant_id' => $tenant->id, - 'policy_id' => $policy->id, + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, @@ -134,11 +125,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings'); diff --git a/tests/Feature/Filament/PolicyVersionTest.php b/tests/Feature/Filament/PolicyVersionTest.php index 4a104e9..e3eec1d 100644 --- a/tests/Feature/Filament/PolicyVersionTest.php +++ b/tests/Feature/Filament/PolicyVersionTest.php @@ -10,13 +10,7 @@ uses(RefreshDatabase::class); test('policy versions render with timeline data', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', - 'name' => 'Tenant One', - 'metadata' => [], - ]); - - $tenant->makeCurrent(); + $tenant = Tenant::factory()->create(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -31,9 +25,7 @@ $service->captureVersion($policy, ['value' => 2], 'tester'); $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $this->actingAs($user) ->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant))) diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index bfbebb7..74bbe57 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -5,20 +5,19 @@ use App\Models\PolicyVersion; use App\Models\SettingsCatalogDefinition; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); it('shows Settings tab for Settings Catalog policy', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -70,11 +69,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -87,13 +81,13 @@ }); it('shows display names instead of definition IDs', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -132,11 +126,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -146,13 +135,13 @@ })->skip('Manual UI verification required'); it('shows fallback prettified labels when definitions not cached', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -186,11 +175,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -200,13 +184,13 @@ })->skip('Manual UI verification required'); it('shows tabbed layout for non-Settings Catalog policies', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'metadata' => [], - 'is_current' => true, + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -233,11 +217,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); - $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -249,11 +228,13 @@ // T034: Test display names shown (not definition IDs) it('displays setting display names instead of raw definition IDs', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'is_current' => true, + 'status' => 'active', ]); + + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); SettingsCatalogDefinition::create([ @@ -292,10 +273,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -306,11 +283,13 @@ // T035: Test values formatted correctly it('formats setting values correctly based on type', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'is_current' => true, + 'status' => 'active', ]); + + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); SettingsCatalogDefinition::create([ @@ -370,10 +349,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -383,11 +358,13 @@ // T036: Test search/filter functionality it('search filters settings in real-time', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'is_current' => true, + 'status' => 'active', ]); + + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); SettingsCatalogDefinition::create([ @@ -436,10 +413,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); @@ -449,11 +422,13 @@ // T037: Test graceful degradation for missing definitions it('shows prettified fallback labels when definitions are not cached', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', - 'is_current' => true, + 'status' => 'active', ]); + + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); $policy = Policy::create([ @@ -485,10 +460,6 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index 9b0e106..f6db31f 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -3,7 +3,6 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -13,10 +12,7 @@ putenv('INTUNE_TENANT_ID='); $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); @@ -80,10 +76,7 @@ ]); $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); @@ -148,10 +141,7 @@ ]); $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $this->actingAs($user); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); diff --git a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php index 0f6f0cf..e4ad978 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php @@ -2,7 +2,6 @@ use App\Models\Policy; use App\Models\Tenant; -use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\BackupService; @@ -104,10 +103,7 @@ public function request(string $method, string $path, array $options = []): Grap metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id], ); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $response = $this ->actingAs($user) @@ -147,10 +143,7 @@ public function request(string $method, string $path, array $options = []): Grap $versions = app(VersionService::class); $versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com'); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $response = $this ->actingAs($user) diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index 0c1dadf..3f42bb0 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -5,7 +5,6 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -89,10 +88,7 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $policyResponse = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index c8dca6f..67d75e6 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -3,7 +3,6 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\PolicySyncService; @@ -110,10 +109,7 @@ public function request(string $method, string $path, array $options = []): Grap ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $response = $this ->actingAs($user) diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index c94859f..5037867 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -4,7 +4,6 @@ use App\Models\BackupSet; use App\Models\Policy; use App\Models\Tenant; -use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\RestoreService; @@ -146,10 +145,7 @@ public function request(string $method, string $path, array $options = []): Grap 'payload' => $payload, ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); $service = app(RestoreService::class); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index d0e3bc3..df8b4dd 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -161,10 +161,7 @@ public function request(string $method, string $path, array $options = []): Grap 'payload' => $payload, ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); $service = app(RestoreService::class); diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index 0bab050..034ea0f 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -5,7 +5,6 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -55,10 +54,7 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $policyResponse = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php index 524f6d9..4a21e10 100644 --- a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -32,7 +32,7 @@ assertNoOutboundHttp(function () use ($tenant): void { $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() - ->assertSee("/admin/t/{$tenant->external_id}/workspaces", false) + ->assertSee('/admin/workspaces', false) ->assertSee('Needs Attention') ->assertSee('Recent Operations') ->assertSee('Recent Drift Findings'); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index cde067b..29ba4df 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -3,7 +3,6 @@ use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Jobs\BulkTenantSyncJob; use App\Models\Tenant; -use App\Models\User; use App\Support\Auth\UiTooltips; use Filament\Events\TenantSet; use Filament\Facades\Filament; @@ -28,15 +27,11 @@ }); test('tenant portfolio tenant view returns 404 for non-member tenant record', function () { - $user = User::factory()->create(); - $this->actingAs($user); - $authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']); $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']); - $user->tenants()->syncWithoutDetaching([ - $authorizedTenant->getKey() => ['role' => 'owner'], - ]); + [$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner'); + $this->actingAs($user); $this->get(route('filament.admin.resources.tenants.view', array_merge( filamentTenantRouteParams($unauthorizedTenant), @@ -45,15 +40,11 @@ }); test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () { - $user = User::factory()->create(); - $this->actingAs($user); - $authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']); $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']); - $user->tenants()->syncWithoutDetaching([ - $authorizedTenant->getKey() => ['role' => 'owner'], - ]); + [$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner'); + $this->actingAs($user); $this->get(route('filament.admin.resources.tenants.edit', array_merge( filamentTenantRouteParams($unauthorizedTenant), @@ -62,9 +53,6 @@ }); test('tenant portfolio lists only tenants the user can access', function () { - $user = User::factory()->create(); - $this->actingAs($user); - $authorizedTenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-portfolio-authorized', 'name' => 'Authorized Tenant', @@ -75,9 +63,8 @@ 'name' => 'Unauthorized Tenant', ]); - $user->tenants()->syncWithoutDetaching([ - $authorizedTenant->getKey() => ['role' => 'owner'], - ]); + [$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner'); + $this->actingAs($user); $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant))) ->assertOk() @@ -88,14 +75,16 @@ test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () { Bus::fake(); - $user = User::factory()->create(); + $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); $this->actingAs($user); - $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']); - $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']); + $tenantB = Tenant::factory()->create([ + 'tenant_id' => 'tenant-bulk-b', + 'workspace_id' => (int) $tenantA->workspace_id, + ]); $user->tenants()->syncWithoutDetaching([ - $tenantA->getKey() => ['role' => 'owner'], $tenantB->getKey() => ['role' => 'operator'], ]); @@ -118,14 +107,9 @@ test('tenant portfolio bulk sync is disabled for readonly users', function () { Bus::fake(); - $user = User::factory()->create(); - $this->actingAs($user); - $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']); - - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'readonly'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly'); + $this->actingAs($user); Filament::setTenant($tenant, true); @@ -146,14 +130,16 @@ test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () { Bus::fake(); - $user = User::factory()->create(); + $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); $this->actingAs($user); - $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']); - $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']); + $tenantB = Tenant::factory()->create([ + 'tenant_id' => 'tenant-bulk-mixed-b', + 'workspace_id' => (int) $tenantA->workspace_id, + ]); $user->tenants()->syncWithoutDetaching([ - $tenantA->getKey() => ['role' => 'owner'], $tenantB->getKey() => ['role' => 'readonly'], ]); diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php index d9cd858..ce26f41 100644 --- a/tests/Feature/Filament/TenantRbacWizardTest.php +++ b/tests/Feature/Filament/TenantRbacWizardTest.php @@ -3,7 +3,6 @@ use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Http\Controllers\RbacDelegatedAuthController; use App\Models\Tenant; -use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use Filament\Facades\Filament; @@ -31,11 +30,8 @@ function tenantWithApp(): Tenant test('rbac action prompts login when no delegated token', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) @@ -54,11 +50,8 @@ function tenantWithApp(): Tenant test('rbac action succeeds and clears token cache', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); @@ -162,11 +155,8 @@ public function request(string $method, string $path, array $options = []): Grap test('rbac action is idempotent on rerun', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); @@ -276,11 +266,8 @@ public function request(string $method, string $path, array $options = []): Grap test('existing group membership error from Graph json payload is treated idempotently', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); @@ -380,11 +367,8 @@ public function request(string $method, string $path, array $options = []): Grap test('group picker is disabled without delegated token', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) @@ -399,11 +383,8 @@ public function request(string $method, string $path, array $options = []): Grap test('group picker toggles when switching modes', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) @@ -417,11 +398,8 @@ public function request(string $method, string $path, array $options = []): Grap test('delegated group search returns options and persists selection', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); @@ -532,11 +510,8 @@ public function request(string $method, string $path, array $options = []): Grap test('delegated role search returns options and persists role definition id', function () { $tenant = tenantWithApp(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $this->actingAs($user); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 1d0ff22..7c7c54e 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -59,9 +59,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-context', 'name' => 'Context Tenant', ]); - $user->tenants()->syncWithoutDetaching([ - $contextTenant->getKey() => ['role' => 'owner'], - ]); + [$user, $contextTenant] = createUserWithTenant($contextTenant, $user, role: 'owner'); + $this->actingAs($user); Filament::setTenant($contextTenant, true); Livewire::test(CreateTenant::class) @@ -134,16 +133,15 @@ public function request(string $method, string $path, array $options = []): Grap }); $user = User::factory()->create(); - $this->actingAs($user); - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-error', 'name' => 'Error Tenant', - ]); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], + 'status' => 'active', ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); + $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) @@ -173,9 +171,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-ui', 'name' => 'UI Tenant', ]); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner'); + $this->actingAs($user); config(['intune_permissions.granted_stub' => []]); @@ -207,9 +204,8 @@ public function request(string $method, string $path, array $options = []): Grap 'app_client_id' => 'client-123', ]); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner'); + $this->actingAs($user); $response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))); @@ -225,9 +221,9 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-ui-deactivate', 'name' => 'UI Tenant Deactivate', ]); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + + [$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner'); + $this->actingAs($user); Filament::setTenant($tenant, true); diff --git a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php index bfc70b8..ee368b7 100644 --- a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php +++ b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php @@ -4,7 +4,6 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -47,10 +46,7 @@ ], ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'owner'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $response = $this->actingAs($user) ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); diff --git a/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php index 9e062c3..d461c58 100644 --- a/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php +++ b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php @@ -5,7 +5,6 @@ use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; -use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -46,16 +45,11 @@ session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey()); $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); - Filament::setTenant(null, true); - $this->actingAs($user) ->get(route('filament.admin.pages.choose-tenant')) - ->assertOk(); - - expect(Filament::getTenant()) - ->toBeInstanceOf(Tenant::class) - ->and(Filament::getTenant()?->getKey()) - ->toBe($tenantA->getKey()); + ->assertOk() + ->assertSee($tenantA->name) + ->assertDontSee($tenantB->name); }); test('user menu renders a workspace switcher when a workspace is selected', function () { diff --git a/tests/Feature/ManagedTenantOnboardingWizardTest.php b/tests/Feature/ManagedTenantOnboardingWizardTest.php new file mode 100644 index 0000000..1c3e11a --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -0,0 +1,874 @@ +create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding") + ->assertNotFound(); +}); + +it('returns 403 for workspace members without onboarding capability', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user) + ->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding") + ->assertForbidden(); +}); + +it('renders onboarding wizard for workspace owners', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding") + ->assertSuccessful(); +}); + +it('allows owners to identify a managed tenant and creates a pending tenant + session', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '11111111-1111-1111-1111-111111111111'; + + Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]) + ->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + expect((int) $tenant->workspace_id)->toBe((int) $workspace->getKey()); + expect($tenant->status)->toBe('pending'); + + $this->assertDatabaseHas('managed_tenant_onboarding_sessions', [ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'current_step' => 'identify', + ]); + + $this->assertDatabaseHas('tenant_memberships', [ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + expect( + (int) \App\Models\TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('role', 'owner') + ->count() + )->toBe(1); +}); + +it('upgrades the initiating user to owner if they already have a lower tenant role', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantGuid = '66666666-6666-6666-6666-666666666666'; + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'tenant_id' => $tenantGuid, + 'name' => 'Acme', + 'status' => 'pending', + ]); + + \App\Models\TenantMembership::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + 'source' => 'manual', + 'created_by_user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user); + + Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]) + ->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $membership = \App\Models\TenantMembership::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('user_id', (int) $user->getKey()) + ->firstOrFail(); + + expect($membership->role)->toBe('owner'); + + expect(\App\Models\TenantMembership::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('user_id', (int) $user->getKey()) + ->count())->toBe(1); +}); + +it('writes audit logs for onboarding start and resume', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '44444444-4444-4444-4444-444444444444'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $this->assertDatabaseHas('audit_logs', [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_id' => (int) $user->getKey(), + 'action' => 'managed_tenant_onboarding.start', + 'resource_type' => 'tenant', + 'resource_id' => (string) $tenant->getKey(), + 'status' => 'success', + ]); + + $this->assertDatabaseHas('audit_logs', [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_id' => (int) $user->getKey(), + 'action' => 'managed_tenant_onboarding.resume', + 'resource_type' => 'tenant', + 'resource_id' => (string) $tenant->getKey(), + 'status' => 'success', + ]); + + expect(AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('resource_type', 'tenant') + ->where('resource_id', (string) $tenant->getKey()) + ->whereIn('action', ['managed_tenant_onboarding.start', 'managed_tenant_onboarding.resume']) + ->count())->toBeGreaterThanOrEqual(2); +}); + +it('blocks demoting or removing the last remaining tenant owner', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '55555555-5555-5555-5555-555555555555'; + + Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]) + ->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + $membership = \App\Models\TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('user_id', $user->getKey()) + ->firstOrFail(); + + expect(fn () => app(TenantMembershipManager::class)->changeRole($tenant, $user, $membership, 'manager')) + ->toThrow(DomainException::class, 'You cannot demote the last remaining owner.'); + + expect(fn () => app(TenantMembershipManager::class)->removeMember($tenant, $user, $membership)) + ->toThrow(DomainException::class, 'You cannot remove the last remaining owner.'); +}); + +it('returns 404 for legacy onboarding entry points', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->get('/admin/register-tenant')->assertNotFound(); + $this->get('/admin/managed-tenants')->assertNotFound(); + $this->get('/admin/managed-tenants/onboarding')->assertNotFound(); + $this->get('/admin/new')->assertNotFound(); +}); + +it('is idempotent when identifying the same managed tenant twice', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '22222222-2222-2222-2222-222222222222'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + expect(TenantOnboardingSession::query() + ->where('workspace_id', $workspace->getKey()) + ->where('tenant_id', $tenant->getKey()) + ->count())->toBe(1); + + $this->assertDatabaseHas('managed_tenant_onboarding_sessions', [ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + ]); +}); + +it('returns 404 and does not create anything when tenant_id exists in another workspace', function (): void { + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantGuid = '33333333-3333-3333-3333-333333333333'; + + Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'tenant_id' => $tenantGuid, + 'name' => 'Acme', + 'status' => 'active', + ]); + + $this->actingAs($user); + + Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspaceB]) + ->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']) + ->assertStatus(404); + + expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1); + + expect(TenantOnboardingSession::query() + ->where('workspace_id', $workspaceB->getKey()) + ->whereIn('tenant_id', Tenant::query()->where('tenant_id', $tenantGuid)->pluck('id')) + ->count())->toBe(0); +}); + +it('binds an unscoped existing tenant to the current workspace when safe and allows identifying it', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantGuid = '77777777-7777-7777-7777-777777777777'; + + $tenant = Tenant::factory()->create([ + 'workspace_id' => null, + 'tenant_id' => $tenantGuid, + 'name' => 'Acme', + 'status' => 'active', + ]); + + \App\Models\TenantMembership::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'created_by_user_id' => (int) $user->getKey(), + ]); + + $this->assertDatabaseHas('tenants', [ + 'id' => (int) $tenant->getKey(), + 'workspace_id' => null, + 'tenant_id' => $tenantGuid, + ]); + + $this->assertDatabaseHas('tenant_memberships', [ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user); + + Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]) + ->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']) + ->assertOk(); + + $this->assertDatabaseHas('tenants', [ + 'id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => $tenantGuid, + ]); +}); + +it('auto-selects the default provider connection and allows switching', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantGuid = '99999999-9999-9999-9999-999999999999'; + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'tenant_id' => $tenantGuid, + 'name' => 'Acme', + 'status' => 'pending', + ]); + + $default = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'display_name' => 'Default', + 'is_default' => true, + ]); + + $other = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft_alt', + 'entra_tenant_id' => $tenantGuid, + 'display_name' => 'Other', + 'is_default' => false, + ]); + + $this->actingAs($user); + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $component->assertSet('selectedProviderConnectionId', (int) $default->getKey()); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + expect($session->state['provider_connection_id'] ?? null)->toBe((int) $default->getKey()); + + $component->call('selectProviderConnection', (int) $other->getKey()); + $component->assertSet('selectedProviderConnectionId', (int) $other->getKey()); + + $session->refresh(); + expect($session->state['provider_connection_id'] ?? null)->toBe((int) $other->getKey()); +}); + +it('dedupes verification runs: starting verification twice returns the active run and dispatches only once', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '77777777-7777-7777-7777-777777777777'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'is_default' => true, + ]); + + $component->set('selectedProviderConnectionId', (int) $connection->getKey()); + + $component->call('startVerification'); + $component->call('startVerification'); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->count())->toBe(1); + + Bus::assertDispatchedTimes(\App\Jobs\ProviderConnectionHealthCheckJob::class, 1); + + $this->assertDatabaseHas('audit_logs', [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_id' => (int) $user->getKey(), + 'action' => 'managed_tenant_onboarding.verification_start', + 'resource_type' => 'operation_run', + ]); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + expect($session->state['verification_operation_run_id'] ?? null)->not->toBeNull(); +}); + +it('creates a provider connection with encrypted credentials and does not persist secrets in session state', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + $secret = 'super-secret-123'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + + $component + ->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']) + ->call('createProviderConnection', [ + 'display_name' => 'Onboarding Connection', + 'client_id' => 'client-id-1', + 'client_secret' => $secret, + 'is_default' => true, + ]) + ->assertDontSee($secret); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->where('entra_tenant_id', $tenantGuid) + ->firstOrFail(); + + expect($connection->credential)->toBeInstanceOf(ProviderCredential::class); + expect($connection->credential->toArray())->not->toHaveKey('payload'); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + $state = $session->state ?? []; + expect($state)->not->toHaveKey('client_id'); + expect($state)->not->toHaveKey('client_secret'); + expect($state['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()); +}); + +it('starts verification, creates an operation run, dispatches the job, and does not include secrets in run context', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $component->set('selectedProviderConnectionId', (int) $connection->getKey()); + $component->call('startVerification'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->firstOrFail(); + + expect($run->context)->toBeArray(); + expect($run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()); + expect($run->context)->not->toHaveKey('client_id'); + expect($run->context)->not->toHaveKey('client_secret'); + + Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); +}); + +it('can resume the latest onboarding session as a different authorized workspace member', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + + $initiator = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $initiator->getKey(), + 'role' => 'owner', + ]); + + $resumer = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $resumer->getKey(), + 'role' => 'manager', + ]); + + $tenantGuid = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; + + $this->actingAs($initiator); + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + $session->update([ + 'state' => array_merge($session->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + ]), + ]); + + $this->actingAs($resumer); + + Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]) + ->call('startVerification'); + + Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); +}); + +it('completes onboarding only after verification succeeded and redirects to tenant dashboard', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = 'dddddddd-dddd-dddd-dddd-dddddddddddd'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $run = OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => sha1('verify-ok-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + $session->update([ + 'state' => array_merge($session->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ]), + ]); + + $component + ->call('completeOnboarding') + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + + $tenant->refresh(); + expect($tenant->status)->toBe('active'); + + $session->refresh(); + expect($session->completed_at)->not->toBeNull(); +}); + +it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $verificationRun = OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => sha1('verify-ok-bootstrap-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + $session->update([ + 'state' => array_merge($session->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + ]), + ]); + + $component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']); + + Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); + Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('type', ['inventory.sync', 'compliance.snapshot']) + ->count())->toBe(2); + + $session->refresh(); + $runs = $session->state['bootstrap_operation_runs'] ?? []; + expect($runs)->toBeArray(); + expect($runs['inventory.sync'] ?? null)->toBeInt(); + expect($runs['compliance.snapshot'] ?? null)->toBeInt(); +}); + +it('returns scope-busy semantics for verification when another run is active for the same connection scope', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '88888888-8888-8888-8888-888888888888'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'is_default' => true, + ]); + + OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $component->set('selectedProviderConnectionId', (int) $connection->getKey()); + + $component->call('startVerification'); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->count())->toBe(0); + + Bus::assertNotDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); +}); + +it('registers the onboarding capability in the canonical registry', function (): void { + expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue(); +}); + +it('maps onboarding capability to owner and manager workspace roles', function (): void { + expect(WorkspaceRoleCapabilityMap::hasCapability('owner', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue(); + expect(WorkspaceRoleCapabilityMap::hasCapability('manager', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue(); + + expect(WorkspaceRoleCapabilityMap::hasCapability('operator', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeFalse(); + expect(WorkspaceRoleCapabilityMap::hasCapability('readonly', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeFalse(); +}); + +it('authorizes onboarding via Gate for owner and manager memberships', function (): void { + $workspace = Workspace::factory()->create(); + + $owner = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $owner->getKey(), + 'role' => 'owner', + ]); + + $manager = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $manager->getKey(), + 'role' => 'manager', + ]); + + $readonly = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $readonly->getKey(), + 'role' => 'readonly', + ]); + + expect(Gate::forUser($owner)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeTrue(); + expect(Gate::forUser($manager)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeTrue(); + expect(Gate::forUser($readonly)->allows(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace))->toBeFalse(); +}); + +it('keeps filament tenant routing key stable (external_id resolves /admin/t/{tenant})', function (): void { + [$user, $tenant] = createUserWithTenant( + Tenant::factory()->create([ + 'workspace_id' => null, + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + ]), + role: 'owner', + ); + + $this->actingAs($user) + ->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertSuccessful(); + + $tenant->refresh(); + + expect($tenant->external_id)->toBe($tenant->tenant_id); +}); + +it('can persist a tenant onboarding session row', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $session = TenantOnboardingSession::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'current_step' => 'identify', + 'state' => ['example' => 'value'], + 'started_by_user_id' => (int) $user->getKey(), + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + expect($session->exists)->toBeTrue(); + $this->assertDatabaseHas('managed_tenant_onboarding_sessions', [ + 'id' => $session->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'current_step' => 'identify', + ]); +}); diff --git a/tests/Feature/ManagedTenants/OnboardingRedirectTest.php b/tests/Feature/ManagedTenants/OnboardingRedirectTest.php index 863c093..d000110 100644 --- a/tests/Feature/ManagedTenants/OnboardingRedirectTest.php +++ b/tests/Feature/ManagedTenants/OnboardingRedirectTest.php @@ -4,7 +4,7 @@ use App\Models\Tenant; -it('redirects /admin/new to the canonical managed-tenant onboarding page', function (): void { +it('does not provide legacy onboarding entry points under /admin/new', function (): void { $tenant = Tenant::factory()->create(); [$user] = createUserWithTenant($tenant, role: 'owner'); @@ -13,5 +13,5 @@ $this->actingAs($user) ->get('/admin/new') - ->assertRedirect('/admin/w/'.$workspace->slug.'/managed-tenants/onboarding'); + ->assertNotFound(); }); diff --git a/tests/Feature/MonitoringOperationsTest.php b/tests/Feature/MonitoringOperationsTest.php index 072fed5..6d3250e 100644 --- a/tests/Feature/MonitoringOperationsTest.php +++ b/tests/Feature/MonitoringOperationsTest.php @@ -8,8 +8,7 @@ it('allows access to monitoring page for tenant members', function () { $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $tenant->users()->attach($user, ['role' => 'owner']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $run = OperationRun::create([ 'tenant_id' => $tenant->id, @@ -28,8 +27,7 @@ it('renders monitoring pages DB-only (never calls Graph)', function () { $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $tenant->users()->attach($user, ['role' => 'owner']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); $run = OperationRun::create([ 'tenant_id' => $tenant->id, @@ -61,8 +59,7 @@ it('shows runs only for current tenant', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); - $user = User::factory()->create(); - $tenantA->users()->attach($user, ['role' => 'owner']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); // We must simulate being in tenant context $this->actingAs($user); @@ -99,8 +96,7 @@ it('allows readonly users to view operations list and detail', function () { $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $tenant->users()->attach($user, ['role' => 'readonly']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly'); $run = OperationRun::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/OpsUx/FailureSanitizationTest.php b/tests/Feature/OpsUx/FailureSanitizationTest.php index 09fd806..a73b279 100644 --- a/tests/Feature/OpsUx/FailureSanitizationTest.php +++ b/tests/Feature/OpsUx/FailureSanitizationTest.php @@ -2,14 +2,12 @@ use App\Filament\Resources\OperationRunResource; use App\Models\Tenant; -use App\Models\User; use App\Services\OperationRunService; use Illuminate\Notifications\DatabaseNotification; it('sanitizes persisted run failures and terminal notifications', function () { $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - $tenant->users()->attach($user, ['role' => 'owner']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index 5b6cdad..0e248d2 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -14,9 +14,7 @@ 'tenant_id' => $this->tenant->id, ]); $this->user = User::factory()->create(); - $this->user->tenants()->syncWithoutDetaching([ - $this->tenant->getKey() => ['role' => 'owner'], - ]); + [$this->user, $this->tenant] = createUserWithTenant($this->tenant, $this->user, role: 'owner'); }); it('displays policy version page', function () { diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php index 61d9851..ba36f1b 100644 --- a/tests/Feature/RunAuthorizationTenantIsolationTest.php +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -3,7 +3,6 @@ use App\Filament\Resources\OperationRunResource; use App\Models\OperationRun; use App\Models\Tenant; -use App\Models\User; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -25,9 +24,10 @@ 'outcome' => 'pending', ]); - $user = User::factory()->create(); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save(); $user->tenants()->syncWithoutDetaching([ - $tenantA->getKey() => ['role' => 'owner'], $tenantB->getKey() => ['role' => 'owner'], ]); @@ -49,9 +49,10 @@ 'outcome' => 'pending', ]); - $user = User::factory()->create(); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save(); $user->tenants()->syncWithoutDetaching([ - $tenantA->getKey() => ['role' => 'owner'], $tenantB->getKey() => ['role' => 'owner'], ]); @@ -70,10 +71,7 @@ 'outcome' => 'pending', ]); - $user = User::factory()->create(); - $user->tenants()->syncWithoutDetaching([ - $tenant->getKey() => ['role' => 'readonly'], - ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly'); $this->actingAs($user) ->get(OperationRunResource::getUrl('index', tenant: $tenant)) diff --git a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php index 18ab164..94d54dd 100644 --- a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php +++ b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -17,7 +17,7 @@ Http::preventStrayRequests(); }); -it('redirects legacy managed-tenants entry to workspace landing when workspace is selected', function (): void { +it('returns 404 for legacy managed-tenants entry even when workspace is selected', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(['slug' => 'acme']); @@ -30,7 +30,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin/managed-tenants') - ->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants"); + ->assertNotFound(); }); it('keeps the managed-tenants landing tenantless even if the user has a tenant in another workspace', function (): void { diff --git a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php index 09a6886..a3c1b69 100644 --- a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php +++ b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php @@ -24,7 +24,7 @@ $this ->actingAs($user) ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]) - ->assertRedirect(route('filament.admin.tenant.registration')); + ->assertRedirect(route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()])); expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); }); diff --git a/tests/Pest.php b/tests/Pest.php index 9209eb8..d12b3ce 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -88,11 +88,26 @@ function assertNoOutboundHttp(Closure $callback): mixed /** * @return array{0: User, 1: Tenant} */ -function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string $role = 'owner'): array -{ +function createUserWithTenant( + ?Tenant $tenant = null, + ?User $user = null, + string $role = 'owner', + ?string $workspaceRole = null, +): array { $user ??= User::factory()->create(); $tenant ??= Tenant::factory()->create(); + $workspaceRole ??= $role; + + $validWorkspaceRoles = array_map( + static fn (\App\Support\Auth\WorkspaceRole $role): string => $role->value, + \App\Support\Auth\WorkspaceRole::cases(), + ); + + if (! in_array($workspaceRole, $validWorkspaceRoles, true)) { + $workspaceRole = \App\Support\Auth\WorkspaceRole::Owner->value; + } + $workspace = null; if ($tenant->workspace_id !== null) { @@ -107,11 +122,11 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string ])->save(); } - WorkspaceMembership::query()->firstOrCreate([ + WorkspaceMembership::query()->updateOrCreate([ 'workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey(), ], [ - 'role' => 'owner', + 'role' => $workspaceRole, ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); diff --git a/tests/Unit/TenantCurrentTest.php b/tests/Unit/TenantCurrentTest.php index ae2014d..80f8a11 100644 --- a/tests/Unit/TenantCurrentTest.php +++ b/tests/Unit/TenantCurrentTest.php @@ -79,7 +79,7 @@ function restoreIntuneTenantId(string|false $original): void 'is_current' => false, ]); - expect(fn () => Tenant::current())->toThrow(\RuntimeException::class, 'No current tenant selected.'); + expect(fn () => Tenant::currentOrFail())->toThrow(\RuntimeException::class, 'No current tenant selected.'); restoreIntuneTenantId($originalEnv); });