From 185f2795c649aeaf9b1a12d622cdc07dc1ae7432 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 13 May 2026 01:31:46 +0200 Subject: [PATCH] feat: retire legacy tenant route surfaces --- .../SeedBackupHealthBrowserFixture.php | 4 +- .../app/Filament/Pages/ChooseTenant.php | 3 +- .../Filament/Pages/CrossTenantComparePage.php | 10 +- .../Filament/Pages/Monitoring/Operations.php | 3 +- .../TenantlessOperationRunViewer.php | 3 +- .../app/Filament/Pages/TenantDashboard.php | 17 +- .../Pages/TenantRequiredPermissions.php | 7 +- .../ManagedTenantOnboardingWizard.php | 6 +- .../Workspaces/ManagedTenantsLanding.php | 4 +- .../Resources/ProviderConnectionResource.php | 15 +- .../Resources/StoredReportResource.php | 6 +- .../app/Filament/Resources/TenantResource.php | 103 +++-- .../Pages/ManageTenantMemberships.php | 10 +- .../RbacDelegatedAuthController.php | 5 +- .../Controllers/SelectTenantController.php | 4 +- .../Providers/Filament/AdminPanelProvider.php | 2 - .../Filament/TenantPanelProvider.php | 142 ------- .../app/Support/ManagedEnvironmentLinks.php | 183 +++++++++ .../EnsureFilamentTenantSelected.php | 2 +- .../Support/OperateHub/OperateHubShell.php | 14 +- .../app/Support/OperationRunLinks.php | 7 +- .../PortfolioArrivalContextResolver.php | 5 +- .../Providers/ProviderReasonTranslator.php | 26 +- .../SupportDiagnosticBundleBuilder.php | 10 +- .../Support/System/SystemDirectoryLinks.php | 9 +- .../Workspaces/WorkspaceIntendedUrl.php | 4 +- .../Workspaces/WorkspaceOverviewBuilder.php | 13 +- .../Workspaces/WorkspaceRedirectResolver.php | 73 +++- .../views/admin-consent-callback.blade.php | 2 +- .../filament/partials/context-bar.blade.php | 2 +- apps/platform/routes/web.php | 30 +- ...192RecordPageHeaderDisciplineSmokeTest.php | 2 +- ...pec281ProviderConnectionScopeSmokeTest.php | 8 +- ...onicalOperationViewerDeepLinkTrustTest.php | 5 +- .../Feature/AdminConsentCallbackTest.php | 3 +- ...nantDashboardProductizationActionsTest.php | 2 +- ...shboardProductizationAuthorizationTest.php | 12 +- ...ntDashboardProductizationReadinessTest.php | 2 +- ...nantDashboardProductizationSummaryTest.php | 8 +- .../tests/Feature/Filament/AdminSmokeTest.php | 6 +- .../EditTenantHeaderDisciplineTest.php | 2 +- .../TenantWorkspaceRemovalTest.php | 7 +- .../TenantDashboardArrivalContextTest.php | 2 +- .../TenantGlobalSearchLifecycleScopeTest.php | 15 +- .../TenantPortfolioContextSwitchTest.php | 14 +- .../TenantRegistryArrivalContextTest.php | 5 +- ...nantResourceIndexIsWorkspaceScopedTest.php | 3 +- .../TenantReviewHeaderDisciplineTest.php | 4 +- .../Feature/Filament/TenantSetupTest.php | 14 +- .../TenantVerificationReportWidgetTest.php | 8 +- ...aceContextTopbarAndTenantSelectionTest.php | 3 +- .../WorkspaceOverviewArrivalContextTest.php | 3 +- .../FindingOutcomeSummaryReportingTest.php | 2 +- .../Guards/ActionSurfaceContractTest.php | 17 +- ...dEnvironmentCanonicalRouteContractTest.php | 63 +++ .../NoActiveTenantResourceRoutesTest.php | 32 ++ .../Guards/NoLegacyTenantPanelRuntimeTest.php | 24 ++ .../OperationRunLinkContractGuardTest.php | 1 - .../CustomerReviewSurfaceLocalizationTest.php | 2 +- .../Monitoring/HeaderContextBarTest.php | 5 +- .../OnboardingDraftLifecycleTest.php | 2 +- ...nectionViewsDbOnlyRenderingSpec081Test.php | 6 +- .../GovernanceReasonPresentationTest.php | 11 +- .../ReviewPackEntitlementEnforcementTest.php | 6 +- .../ReviewPack/ReviewPackResourceTest.php | 2 +- .../ReviewPack/ReviewPackWidgetTest.php | 16 +- ...rkspaceManagedTenantAdminMigrationTest.php | 49 ++- .../StoredReportDetailPresentationTest.php | 4 +- ...StoredReportEntitlementEnforcementTest.php | 2 +- .../StoredReportResourceTest.php | 6 +- .../ProductKnowledgeAuthorizationTest.php | 2 +- ...ductKnowledgeSupportDiagnosticHelpTest.php | 2 +- ...TelemetrySupportDiagnosticsCaptureTest.php | 2 +- .../SupportDiagnosticAuditTest.php | 2 +- .../SupportDiagnosticAuthorizationTest.php | 2 +- .../TenantSupportDiagnosticActionTest.php | 2 +- .../SupportRequestAuditTest.php | 2 +- .../SupportRequestAuthorizationTest.php | 2 +- ...SupportRequestExternalHandoffAuditTest.php | 2 +- ...equestExternalHandoffAuthorizationTest.php | 2 +- .../TenantSupportRequestActionTest.php | 2 +- ...enantSupportRequestExternalHandoffTest.php | 2 +- .../NoAdHocTelemetryBypassTest.php | 2 +- .../TenantReview/TenantReviewAuditLogTest.php | 2 +- .../TenantReviewExecutivePackTest.php | 2 +- .../TenantReviewExplanationSurfaceTest.php | 4 +- .../TenantReviewExportOperationsUxTest.php | 4 +- .../TenantReview/TenantReviewRbacTest.php | 2 +- .../TenantReviewUiContractTest.php | 10 +- ...orkspaceIntendedUrlLegacyRejectionTest.php | 65 +++ apps/platform/tests/Pest.php | 5 +- ...ionResourceLivewireTenantInferenceTest.php | 4 +- .../checklists/requirements.md | 51 +++ ...ed-environment-canonical-route-contract.md | 87 ++++ .../data-model.md | 61 +++ .../legacy-surface-audit.md | 92 +++++ .../plan.md | 314 +++++++++++++++ .../quickstart.md | 95 +++++ .../research.md | 78 ++++ .../spec.md | 378 ++++++++++++++++++ .../tasks.md | 190 +++++++++ 101 files changed, 2119 insertions(+), 453 deletions(-) delete mode 100644 apps/platform/app/Providers/Filament/TenantPanelProvider.php create mode 100644 apps/platform/app/Support/ManagedEnvironmentLinks.php create mode 100644 apps/platform/tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php create mode 100644 apps/platform/tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php create mode 100644 apps/platform/tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php create mode 100644 apps/platform/tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php create mode 100644 specs/297-managed-environment-canonical-route-cutover/checklists/requirements.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/contracts/managed-environment-canonical-route-contract.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/data-model.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/plan.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/quickstart.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/research.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/spec.md create mode 100644 specs/297-managed-environment-canonical-route-cutover/tasks.md diff --git a/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php b/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php index 93b0544c..660be425 100644 --- a/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php +++ b/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php @@ -4,7 +4,6 @@ namespace App\Console\Commands; -use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\BackupSetResource; use App\Models\BackupItem; use App\Models\BackupSet; @@ -15,6 +14,7 @@ use App\Models\UserTenantPreference; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\ManagedEnvironmentLinks; use Illuminate\Console\Command; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; @@ -174,7 +174,7 @@ public function handle(): int ['User password', $password], ['ManagedEnvironment', (string) $tenant->name], ['ManagedEnvironment external id', (string) $tenant->external_id], - ['Dashboard URL', TenantDashboard::getUrl(tenant: $tenant)], + ['Dashboard URL', ManagedEnvironmentLinks::viewUrl($tenant)], ['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)], ['Blocked route', BackupSetResource::getUrl(panel: 'admin', tenant: $tenant)], ['Locally denied capability', 'tenant.view'], diff --git a/apps/platform/app/Filament/Pages/ChooseTenant.php b/apps/platform/app/Filament/Pages/ChooseTenant.php index 28921db3..3bfa1f8f 100644 --- a/apps/platform/app/Filament/Pages/ChooseTenant.php +++ b/apps/platform/app/Filament/Pages/ChooseTenant.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Models\UserTenantPreference; use App\Services\Tenants\TenantOperabilityService; +use App\Support\ManagedEnvironmentLinks; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantLifecyclePresentation; use App\Support\Tenants\TenantOperabilityQuestion; @@ -129,7 +130,7 @@ public function selectTenant(int $tenantId): void abort(404); } - $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + $this->redirect(ManagedEnvironmentLinks::viewUrl($tenant)); } public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation diff --git a/apps/platform/app/Filament/Pages/CrossTenantComparePage.php b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php index d577f3f0..4558695f 100644 --- a/apps/platform/app/Filament/Pages/CrossTenantComparePage.php +++ b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php @@ -4,7 +4,6 @@ namespace App\Filament\Pages; -use App\Filament\Resources\TenantResource; use App\Models\InventoryItem; use App\Models\ManagedEnvironment; use App\Models\User; @@ -14,6 +13,7 @@ use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService; use App\Support\Auth\Capabilities; +use App\Support\ManagedEnvironmentLinks; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationalControls\OperationalControlBlockedException; use App\Support\OperationRunLinks; @@ -183,7 +183,7 @@ protected function getHeaderActions(): array ->label('Open source tenant') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') - ->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin')); + ->url(ManagedEnvironmentLinks::viewUrl($sourceTenant)); } $targetTenant = $this->selectedTargetTenant(); @@ -193,7 +193,7 @@ protected function getHeaderActions(): array ->label('Open target tenant') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') - ->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin')); + ->url(ManagedEnvironmentLinks::viewUrl($targetTenant)); } $preflightAction = Action::make('generatePromotionPreflight') @@ -446,7 +446,7 @@ public function sourceTenantUrl(): ?string return null; } - return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + return ManagedEnvironmentLinks::viewUrl($tenant); } public function targetTenantUrl(): ?string @@ -457,7 +457,7 @@ public function targetTenantUrl(): ?string return null; } - return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + return ManagedEnvironmentLinks::viewUrl($tenant); } /** diff --git a/apps/platform/app/Filament/Pages/Monitoring/Operations.php b/apps/platform/app/Filament/Pages/Monitoring/Operations.php index 4cb1c552..169ca598 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/Operations.php +++ b/apps/platform/app/Filament/Pages/Monitoring/Operations.php @@ -10,6 +10,7 @@ use App\Models\ManagedEnvironment; use App\Models\Workspace; use App\Support\Filament\CanonicalAdminTenantFilterState; +use App\Support\ManagedEnvironmentLinks; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunLinks; @@ -255,7 +256,7 @@ protected function getHeaderActions(): array ->label('Back to '.$activeTenant->name) ->icon('heroicon-o-arrow-left') ->color('gray') - ->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant)); + ->url(ManagedEnvironmentLinks::viewUrl($activeTenant)); } if ($activeTenant instanceof ManagedEnvironment) { diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 9c1cab9b..6cb81b19 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -15,6 +15,7 @@ use App\Services\Baselines\BaselineEvidenceCaptureResumeService; use App\Services\Tenants\TenantOperabilityService; use App\Support\Auth\Capabilities; +use App\Support\ManagedEnvironmentLinks; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\RelatedNavigationResolver; use App\Support\OperateHub\OperateHubShell; @@ -126,7 +127,7 @@ protected function getHeaderActions(): array $actions[] = Action::make('operate_hub_back_to_tenant_run_detail') ->label('← Back to '.$activeTenant->name) ->color('gray') - ->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant)); + ->url(ManagedEnvironmentLinks::viewUrl($activeTenant)); } else { $actions[] = Action::make('operate_hub_back_to_operations') ->label('Back to Operations') diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 981acd70..c8656c03 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -11,10 +11,10 @@ use App\Models\SupportRequest; use App\Models\ManagedEnvironment; use App\Models\User; -use App\Models\Workspace; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; +use App\Support\ManagedEnvironmentLinks; use App\Support\ProductTelemetry\ProductTelemetryRecorder; use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\Rbac\UiEnforcement; @@ -101,22 +101,9 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ? return url('/admin'); } - $workspace = $parameters['workspace'] ?? null; - - if (! $workspace instanceof Workspace) { - $workspace = $resolvedTenant->workspace()->first(); - } - - if (! $workspace instanceof Workspace) { - return url('/admin'); - } - - $url = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$resolvedTenant->getRouteKey()); $query = array_diff_key($parameters, array_flip(['tenant', 'environment', 'workspace'])); - return $query === [] - ? $url - : $url.'?'.http_build_query($query); + return ManagedEnvironmentLinks::viewUrl($resolvedTenant, $query); } /** diff --git a/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php b/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php index c9322b13..4fba214d 100644 --- a/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php +++ b/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php @@ -4,8 +4,6 @@ namespace App\Filament\Pages; -use App\Filament\Resources\ProviderConnectionResource; -use App\Filament\Resources\TenantResource; use App\Models\ManagedEnvironment; use App\Models\User; use App\Models\WorkspaceMembership; @@ -13,6 +11,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Filament\TablePaginationProfiles; +use App\Support\ManagedEnvironmentLinks; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; @@ -207,7 +206,7 @@ public function reRunVerificationUrl(): string $tenant = $this->trustedScopedTenant(); if ($tenant instanceof ManagedEnvironment) { - return TenantResource::getUrl('view', ['record' => $tenant]); + return ManagedEnvironmentLinks::viewUrl($tenant); } return route('admin.onboarding'); @@ -221,7 +220,7 @@ public function manageProviderConnectionUrl(): ?string return null; } - return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); + return ManagedEnvironmentLinks::providerConnectionsUrl($tenant); } protected static function resolveScopedTenant(ManagedEnvironment|string|null $tenant = null): ?ManagedEnvironment diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 0daee766..63bd8558 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -7,7 +7,6 @@ use BackedEnum; use App\Exceptions\Onboarding\OnboardingDraftConflictException; use App\Exceptions\Onboarding\OnboardingDraftImmutableException; -use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\TenantResource; use App\Filament\Support\VerificationReportChangeIndicator; use App\Filament\Support\VerificationReportViewer; @@ -43,6 +42,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Links\RequiredPermissionsLinks; use App\Support\Livewire\TrustedState\TrustedStateResolver; +use App\Support\ManagedEnvironmentLinks; use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingDraftStage; use App\Support\Onboarding\OnboardingLifecycleState; @@ -189,7 +189,7 @@ protected function getHeaderActions(): array $actions[] = Action::make('view_linked_tenant') ->label($this->linkedTenantActionLabel()) ->color('gray') - ->url($tenant instanceof ManagedEnvironment ? TenantResource::getUrl('view', ['record' => $tenant]) : null); + ->url($tenant instanceof ManagedEnvironment ? ManagedEnvironmentLinks::viewUrl($tenant) : null); } if ($this->canResumeDraft($draft)) { @@ -5240,7 +5240,7 @@ public function completeOnboarding(): void resourceId: (string) $tenant->getKey(), ); - $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + $this->redirect(ManagedEnvironmentLinks::viewUrl($tenant)); } private function verificationRun(): ?OperationRun diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php index 8705fab1..a7d9fcff 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -5,7 +5,6 @@ namespace App\Filament\Pages\Workspaces; use App\Filament\Pages\ChooseTenant; -use App\Filament\Resources\TenantResource; use App\Models\ManagedEnvironment; use App\Models\User; use App\Models\Workspace; @@ -13,6 +12,7 @@ use App\Services\Tenants\TenantOperabilityService; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantOperabilityQuestion; +use App\Support\ManagedEnvironmentLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Pages\Page; use Illuminate\Database\Eloquent\Collection; @@ -118,7 +118,7 @@ public function openTenant(int $tenantId): void } $this->redirect( - \App\Filament\Pages\TenantDashboard::getUrl(tenant: $tenant) + ManagedEnvironmentLinks::viewUrl($tenant) ); } } diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index f931479d..037477b9 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -20,6 +20,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Filament\TablePaginationProfiles; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperationRunLinks; use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; @@ -284,17 +285,7 @@ private static function extractTenantExternalIdFromUrl(string $url): ?string } } - $path = parse_url($url, PHP_URL_PATH); - - if (! is_string($path) || $path === '') { - $path = $url; - } - - if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) { - return null; - } - - return (string) $matches[1]; + return null; } private static function resolveTenantByExternalId(?string $externalId): ?ManagedEnvironment @@ -828,7 +819,7 @@ public static function table(Table $table): Table return null; } - return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + return ManagedEnvironmentLinks::viewUrl($tenant); }), Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(), Tables\Columns\TextColumn::make('provider') diff --git a/apps/platform/app/Filament/Resources/StoredReportResource.php b/apps/platform/app/Filament/Resources/StoredReportResource.php index f40a51b8..15182565 100644 --- a/apps/platform/app/Filament/Resources/StoredReportResource.php +++ b/apps/platform/app/Filament/Resources/StoredReportResource.php @@ -7,7 +7,6 @@ use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\WorkspaceScopedTenantRoutes; -use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\StoredReportResource\Pages; use App\Models\StoredReport; use App\Models\ManagedEnvironment; @@ -16,6 +15,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Filament\TablePaginationProfiles; +use App\Support\ManagedEnvironmentLinks; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -374,7 +374,7 @@ public static function table(Table $table): Table ->emptyStateIcon('heroicon-o-document-chart-bar') ->emptyStateActions([ Actions\Action::make('open_tenant_overview') - ->label('Open tenant overview') + ->label('Open environment overview') ->icon('heroicon-o-home') ->url(fn (): string => static::tenantOverviewUrl()), ]); @@ -715,6 +715,6 @@ private static function tenantOverviewUrl(): string return '#'; } - return TenantDashboard::getUrl(tenant: $tenant); + return ManagedEnvironmentLinks::viewUrl($tenant); } } diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index de67ff8b..6c1654ae 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -3,7 +3,6 @@ namespace App\Filament\Resources; use App\Filament\Pages\CrossTenantComparePage; -use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\RelationManagers; use App\Http\Controllers\RbacDelegatedAuthController; @@ -43,6 +42,7 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\Filament\TablePaginationProfiles; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter; @@ -102,6 +102,8 @@ class TenantResource extends Resource protected static bool $isScopedToTenant = false; + protected static bool $isGloballySearchable = false; + protected static ?string $recordTitleAttribute = 'name'; protected static ?string $recordRouteKeyName = 'slug'; @@ -226,7 +228,7 @@ public static function makeMembershipsAction(): Actions\Action Actions\Action::make('memberships') ->label('Manage access scope') ->icon('heroicon-o-users') - ->url(fn (ManagedEnvironment $record): string => static::getUrl('memberships', ['record' => $record], panel: 'admin')), + ->url(fn (ManagedEnvironment $record): string => ManagedEnvironmentLinks::accessScopesUrl($record)), ) ->requireCapability(Capabilities::TENANT_MEMBERSHIP_VIEW) ->tooltip('You do not have permission to view environment access scopes.') @@ -411,11 +413,11 @@ public static function makeRemoveTenantFromWorkspaceAction(?string $permissionTo { $builder = UiEnforcement::forAction( Actions\Action::make('remove_from_workspace') - ->label('Remove tenant') + ->label('Remove environment') ->color('danger') ->icon('heroicon-o-no-symbol') ->requiresConfirmation() - ->modalHeading('Remove tenant from workspace') + ->modalHeading('Remove environment from workspace') ->modalDescription('The tenant remains available for audit, operation history, evidence, and administrative inspection, but it is no longer selectable as active tenant context.') ->form([ Forms\Components\Textarea::make('removal_reason') @@ -464,11 +466,11 @@ public static function makeRestoreTenantToWorkspaceAction(?string $permissionToo { $builder = UiEnforcement::forAction( Actions\Action::make('restore_to_workspace') - ->label('Restore tenant') + ->label('Restore environment') ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() - ->modalHeading('Restore tenant to workspace') + ->modalHeading('Restore environment to workspace') ->modalDescription('Restoring the tenant makes it eligible for normal workspace tenant selection and new tenant operations again, subject to its lifecycle and RBAC.') ->form([ Forms\Components\Textarea::make('restore_reason') @@ -767,14 +769,7 @@ public static function getEloquentQuery(): Builder public static function getGlobalSearchEloquentQuery(): Builder { - if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) { - return static::getEloquentQuery()->whereRaw('1 = 0'); - } - - return static::tenantOperability()->applyAdministrativeDiscoverabilityScope( - static::getEloquentQuery(), - (new ManagedEnvironment)->getTable(), - ); + return static::getEloquentQuery()->whereRaw('1 = 0'); } public static function table(Table $table): Table @@ -1355,15 +1350,12 @@ public static function tenantDashboardOpenUrl(ManagedEnvironment $record, array $arrivalState = static::portfolioArrivalStateForTenant($record, $triageState); if ($arrivalState === null) { - return TenantDashboard::getUrl(tenant: $record); + return ManagedEnvironmentLinks::viewUrl($record); } - return TenantDashboard::getUrl( - parameters: [ - PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState), - ], - tenant: $record, - ); + return ManagedEnvironmentLinks::viewUrl($record, [ + PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState), + ]); } /** @@ -2805,7 +2797,7 @@ public static function tenantViewContextEntries(ManagedEnvironment $tenant): arr label: 'Provider connections', value: 'Open provider connections', secondaryValue: 'Inspect consent, credentials, and health for this tenant.', - targetUrl: ProviderConnectionResource::getUrl('index', ['managed_environment_id' => $tenant->external_id], panel: 'admin'), + targetUrl: ManagedEnvironmentLinks::providerConnectionsUrl($tenant), targetKind: 'canonical_page', priority: 20, actionLabel: 'Open', @@ -2859,7 +2851,7 @@ public static function tenantEditContextEntries(ManagedEnvironment $tenant): arr RelatedContextEntry::available( key: 'tenant_view', label: 'ManagedEnvironment detail', - value: 'Open tenant detail', + value: 'Open environment detail', secondaryValue: 'Review verification, RBAC, and lifecycle context without leaving the tenant resource.', targetUrl: static::getUrl('view', ['record' => $tenant]), targetKind: 'direct_record', @@ -3146,9 +3138,64 @@ public static function getRelations(): array */ public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string { - $panel ??= 'admin'; + $environment = static::resolveUrlEnvironment( + $parameters['record'] + ?? $parameters['tenant'] + ?? $parameters['environment'] + ?? $tenant + ?? null, + ); - return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters); + $query = $parameters; + unset($query['record'], $query['tenant'], $query['environment'], $query['workspace']); + + if ($name === 'memberships' && $environment instanceof ManagedEnvironment) { + return ManagedEnvironmentLinks::accessScopesUrl($environment, $query); + } + + if (($name === 'view' || $name === 'edit') && $environment instanceof ManagedEnvironment) { + return ManagedEnvironmentLinks::viewUrl($environment, $query); + } + + if ($name === null || $name === 'index') { + $scope = $environment ?? ($parameters['workspace'] ?? null); + + return ManagedEnvironmentLinks::indexUrl($scope instanceof Workspace || $scope instanceof ManagedEnvironment ? $scope : null, $query); + } + + if ($environment instanceof ManagedEnvironment) { + return ManagedEnvironmentLinks::viewUrl($environment, $query); + } + + return ManagedEnvironmentLinks::indexUrl(query: $query); + } + + private static function resolveUrlEnvironment(mixed $environment): ?ManagedEnvironment + { + if ($environment instanceof ManagedEnvironment) { + return $environment; + } + + if ($environment instanceof Model) { + return null; + } + + $identifier = trim((string) $environment); + + if ($identifier === '') { + return null; + } + + return ManagedEnvironment::query() + ->withTrashed() + ->where(static function (Builder $query) use ($identifier): void { + $query->where('slug', $identifier); + + if (ctype_digit($identifier)) { + $query->orWhere((new ManagedEnvironment)->getQualifiedKeyName(), (int) $identifier); + } + }) + ->first(); } public static function rbacAction(): Actions\Action @@ -3275,9 +3322,7 @@ public static function rbacAction(): Actions\Action ->label('Open RBAC login') ->url(route('admin.rbac.start', [ 'tenant' => $record->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', [ - 'record' => $record, - ]), + 'return' => ManagedEnvironmentLinks::viewUrl($record), ])), ]) ->warning() @@ -3378,7 +3423,7 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string */ private static function providerConnectionState(ManagedEnvironment $tenant): array { - $ctaUrl = ProviderConnectionResource::getUrl('index', ['managed_environment_id' => (string) $tenant->external_id], panel: 'admin'); + $ctaUrl = ManagedEnvironmentLinks::providerConnectionsUrl($tenant); $defaultConnection = ProviderConnection::query() ->where('managed_environment_id', (int) $tenant->getKey()) diff --git a/apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php b/apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php index 0d5e1687..b238f59e 100644 --- a/apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php +++ b/apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php @@ -2,13 +2,19 @@ namespace App\Filament\Resources\TenantResource\Pages; -use App\Filament\Resources\TenantResource; +use App\Models\ManagedEnvironment; +use App\Support\ManagedEnvironmentLinks; use Filament\Actions\Action; class ManageTenantMemberships extends ViewTenant { protected static ?string $title = 'Manage environment access scope'; + public function mount(int|string|ManagedEnvironment $tenant): void + { + parent::mount($tenant instanceof ManagedEnvironment ? (string) $tenant->getRouteKey() : $tenant); + } + public function getSubheading(): ?string { return 'Workspace membership defines the role. Explicit environment scopes only narrow which workspace members can see this environment.'; @@ -31,7 +37,7 @@ protected function getHeaderActions(): array Action::make('back_to_overview') ->label('Back to environment overview') ->color('gray') - ->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')), + ->url(fn (): string => ManagedEnvironmentLinks::viewUrl($this->getRecord())), ); return $actions; diff --git a/apps/platform/app/Http/Controllers/RbacDelegatedAuthController.php b/apps/platform/app/Http/Controllers/RbacDelegatedAuthController.php index a9c01bf4..019f4e6d 100644 --- a/apps/platform/app/Http/Controllers/RbacDelegatedAuthController.php +++ b/apps/platform/app/Http/Controllers/RbacDelegatedAuthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\ManagedEnvironment; +use App\Support\ManagedEnvironmentLinks; use Carbon\CarbonImmutable; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -33,7 +34,7 @@ public function start(Request $request): RedirectResponse $request->session()->put('rbac_tenant', $tenant->getKey()); $returnTo = $this->sanitizeReturnPath( $request->string('return')->toString() - ?: route('filament.admin.resources.tenants.view', $tenant) + ?: ManagedEnvironmentLinks::viewUrl($tenant) ); $request->session()->put('rbac_return', $returnTo); @@ -91,7 +92,7 @@ public function callback(Request $request): RedirectResponse Cache::put($this->cacheKey($tenant, auth()->id(), $request->session()->getId()), $accessToken, $ttl); - $destination = $this->sanitizeReturnPath($returnTo) ?: route('filament.admin.resources.tenants.view', $tenant); + $destination = $this->sanitizeReturnPath($returnTo) ?: ManagedEnvironmentLinks::viewUrl($tenant); return redirect()->to($destination); } diff --git a/apps/platform/app/Http/Controllers/SelectTenantController.php b/apps/platform/app/Http/Controllers/SelectTenantController.php index 94055a55..ff9ff7f9 100644 --- a/apps/platform/app/Http/Controllers/SelectTenantController.php +++ b/apps/platform/app/Http/Controllers/SelectTenantController.php @@ -4,13 +4,13 @@ namespace App\Http\Controllers; -use App\Filament\Pages\TenantDashboard; use App\Models\ManagedEnvironment; use App\Models\User; use App\Models\UserTenantPreference; use App\Services\Tenants\TenantOperabilityService; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantOperabilityQuestion; +use App\Support\ManagedEnvironmentLinks; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -67,7 +67,7 @@ public function __invoke(Request $request): RedirectResponse abort(404); } - return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + return redirect()->to(ManagedEnvironmentLinks::viewUrl($tenant)); } private function persistLastTenant(User $user, ManagedEnvironment $tenant): void diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 99c2f01f..1c5ffce5 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -28,7 +28,6 @@ use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; -use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\User; @@ -174,7 +173,6 @@ public function panel(Panel $panel): Panel : '') ) ->resources([ - TenantResource::class, PolicyResource::class, ProviderConnectionResource::class, InventoryItemResource::class, diff --git a/apps/platform/app/Providers/Filament/TenantPanelProvider.php b/apps/platform/app/Providers/Filament/TenantPanelProvider.php deleted file mode 100644 index 1a1dbbf7..00000000 --- a/apps/platform/app/Providers/Filament/TenantPanelProvider.php +++ /dev/null @@ -1,142 +0,0 @@ -id('tenant') - ->path('admin/t') - ->login(Login::class) - ->brandName('TenantPilot') - ->brandLogo(fn () => view('filament.admin.logo')) - ->brandLogoHeight('2rem') - ->favicon(asset('favicon.ico')) - ->font(null, provider: LocalFontProvider::class, preload: []) - ->tenant(ManagedEnvironment::class, slugAttribute: 'slug') - ->tenantRoutePrefix(null) - ->tenantMenu(fn (): bool => filled(Filament::getTenant())) - ->searchableTenantMenu() - ->colors([ - 'primary' => Color::Indigo, - ]) - ->navigationItems([ - NavigationItem::make(fn (): string => __('localization.navigation.operations')) - ->url(fn (): string => OperationRunLinks::index()) - ->icon('heroicon-o-queue-list') - ->group(fn (): string => __('localization.navigation.monitoring')) - ->sort(10), - NavigationItem::make(fn (): string => __('localization.navigation.alerts')) - ->url(fn (): string => url('/admin/alerts')) - ->icon('heroicon-o-bell-alert') - ->group(fn (): string => __('localization.navigation.monitoring')) - ->sort(20), - NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) - ->url(fn (): string => route('admin.monitoring.audit-log')) - ->icon('heroicon-o-clipboard-document-list') - ->group(fn (): string => __('localization.navigation.monitoring')) - ->sort(30), - ]) - ->renderHook( - PanelsRenderHook::HEAD_END, - fn () => view('filament.partials.livewire-intercept-shim')->render() - ) - ->renderHook( - PanelsRenderHook::TOPBAR_START, - fn () => view('filament.partials.context-bar')->render() - ) - ->renderHook( - PanelsRenderHook::CONTENT_START, - fn (): string => $this->shouldRenderBulkOperationProgressWidget() - ? view('livewire.bulk-operation-progress-wrapper')->render() - : '' - ) - ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters') - ->resources([ - TenantReviewResource::class, - ]) - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') - ->pages([ - TenantDashboard::class, - ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') - ->widgets([ - AccountWidget::class, - FilamentInfoWidget::class, - ]) - ->databaseNotifications() - ->databaseNotificationsPolling(null) - ->middleware([ - EncryptCookies::class, - AddQueuedCookiesToResponse::class, - StartSession::class, - AuthenticateSession::class, - ShareErrorsFromSession::class, - VerifyCsrfToken::class, - SubstituteBindings::class, - 'ensure-correct-guard:web', - 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', - DenyNonMemberTenantAccess::class, - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - ]) - ->middleware(['apply-resolved-locale:tenant'], isPersistent: true) - ->authMiddleware([ - Authenticate::class, - ]); - - $theme = PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'); - - if (is_string($theme)) { - $panel->theme($theme); - } - - return $panel; - } - - private function shouldRenderBulkOperationProgressWidget(): bool - { - if (! (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)) { - return false; - } - - $segments = request()->segments(); - - return ! ( - count($segments) === 3 - && ($segments[0] ?? null) === 'admin' - && ($segments[1] ?? null) === 't' - ); - } -} diff --git a/apps/platform/app/Support/ManagedEnvironmentLinks.php b/apps/platform/app/Support/ManagedEnvironmentLinks.php new file mode 100644 index 00000000..4c9afc5a --- /dev/null +++ b/apps/platform/app/Support/ManagedEnvironmentLinks.php @@ -0,0 +1,183 @@ + $query + */ + public static function indexUrl(Workspace|ManagedEnvironment|null $scope = null, array $query = []): string + { + $workspace = self::workspaceFromScope($scope); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + + return self::withQuery(route('admin.workspace.managed-tenants.index', [ + 'workspace' => self::workspaceRouteKey($workspace), + ]), $query); + } + + /** + * @param array $query + */ + public static function viewUrl(ManagedEnvironment $environment, array $query = []): string + { + $workspace = self::workspaceFromScope($environment); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + + return self::withQuery(route('admin.workspace.environments.show', [ + 'workspace' => self::workspaceRouteKey($workspace), + 'tenant' => self::environmentRouteKey($environment), + ]), $query); + } + + /** + * @param array $filters + */ + public static function requiredPermissionsUrl(ManagedEnvironment $environment, array $filters = []): string + { + return RequiredPermissionsLinks::requiredPermissions($environment, $filters); + } + + /** + * @param array $query + */ + public static function diagnosticsUrl(ManagedEnvironment $environment, array $query = []): string + { + return self::environmentChildUrl('admin.workspace.environments.diagnostics', $environment, $query); + } + + /** + * @param array $query + */ + public static function accessScopesUrl(ManagedEnvironment $environment, array $query = []): string + { + return self::environmentChildUrl('admin.workspace.environments.access-scopes', $environment, $query); + } + + /** + * @param array $query + */ + public static function providerConnectionsUrl(?ManagedEnvironment $environment = null, array $query = []): string + { + if ($environment instanceof ManagedEnvironment && ! array_key_exists('managed_environment_id', $query)) { + $query['managed_environment_id'] = (string) $environment->external_id; + } + + return ProviderConnectionResource::getUrl('index', $query, panel: 'admin'); + } + + /** + * @param array $query + */ + public static function providerConnectionUrl( + ProviderConnection|int $connection, + string $page = 'view', + ?ManagedEnvironment $environment = null, + array $query = [], + ): string { + if ($environment instanceof ManagedEnvironment && ! array_key_exists('managed_environment_id', $query)) { + $query['managed_environment_id'] = (string) $environment->external_id; + } + + $query['record'] = $connection; + + return ProviderConnectionResource::getUrl($page, $query, panel: 'admin'); + } + + /** + * @param array $query + */ + public static function operationsUrl(Workspace|ManagedEnvironment|null $scope = null, array $query = []): string + { + if ($scope instanceof ManagedEnvironment && ! array_key_exists('managed_environment_id', $query)) { + $query['managed_environment_id'] = (int) $scope->getKey(); + } + + $workspace = self::workspaceFromScope($scope); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + + return self::withQuery(route('admin.operations.index', [ + 'workspace' => self::workspaceRouteKey($workspace), + ]), $query); + } + + public static function workspaceRouteKey(Workspace $workspace): string + { + $slug = $workspace->getAttribute('slug'); + + return is_string($slug) && $slug !== '' + ? $slug + : (string) $workspace->getKey(); + } + + public static function environmentRouteKey(ManagedEnvironment $environment): string + { + return (string) $environment->getRouteKey(); + } + + /** + * @param array $query + */ + private static function environmentChildUrl(string $routeName, ManagedEnvironment $environment, array $query = []): string + { + $workspace = self::workspaceFromScope($environment); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + + return self::withQuery(route($routeName, [ + 'workspace' => self::workspaceRouteKey($workspace), + 'tenant' => self::environmentRouteKey($environment), + ]), $query); + } + + private static function workspaceFromScope(Workspace|ManagedEnvironment|null $scope = null): ?Workspace + { + if ($scope instanceof Workspace) { + return $scope; + } + + if ($scope instanceof ManagedEnvironment) { + return $scope->workspace()->first(); + } + + return app(WorkspaceContext::class)->currentWorkspace(request()); + } + + /** + * @param array $query + */ + private static function withQuery(string $url, array $query): string + { + $query = array_filter( + $query, + static fn (mixed $value): bool => $value !== null && $value !== '', + ); + + if ($query === []) { + return $url; + } + + $queryString = http_build_query($query); + + return $queryString !== '' ? "{$url}?{$queryString}" : $url; + } +} diff --git a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php index d8ededf1..d5a8e6ff 100644 --- a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -242,7 +242,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void private function isWorkspaceScopedPageWithTenant(string $path): bool { - return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/required-permissions$#', $path) === 1; + return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/(required-permissions|diagnostics|access-scopes)$#', $path) === 1; } private function isLivewireUpdatePath(string $path): bool diff --git a/apps/platform/app/Support/OperateHub/OperateHubShell.php b/apps/platform/app/Support/OperateHub/OperateHubShell.php index d91d5dc6..46fc88c1 100644 --- a/apps/platform/app/Support/OperateHub/OperateHubShell.php +++ b/apps/platform/app/Support/OperateHub/OperateHubShell.php @@ -4,12 +4,12 @@ namespace App\Support\OperateHub; -use App\Filament\Pages\TenantDashboard; use App\Models\ManagedEnvironment; use App\Models\User; use App\Models\Workspace; use App\Services\Auth\CapabilityResolver; use App\Services\Tenants\TenantOperabilityService; +use App\Support\ManagedEnvironmentLinks; use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantPageCategory; use App\Support\Workspaces\WorkspaceContext; @@ -48,7 +48,7 @@ public function returnAffordance(?Request $request = null): ?array if ($activeTenant instanceof ManagedEnvironment) { return [ 'label' => 'Back to '.$activeTenant->name, - 'url' => TenantDashboard::getUrl(tenant: $activeTenant), + 'url' => ManagedEnvironmentLinks::viewUrl($activeTenant), ]; } @@ -346,15 +346,7 @@ private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPa return $this->resolveTenantIdentifier($route->parameter('tenant')); } - if ( - $pageCategory !== TenantPageCategory::TenantBound - || ! $route?->hasParameter('record') - || ! str_starts_with((string) ($route->getName() ?? ''), 'filament.admin.resources.tenants.') - ) { - return null; - } - - return $this->resolveTenantIdentifier($route->parameter('record')); + return null; } private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvironment diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index ba0fb01c..716a6931 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -10,7 +10,6 @@ use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; -use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\ReviewPackResource; use App\Filament\Resources\TenantReviewResource; @@ -166,9 +165,9 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): $providerConnectionId = $context['provider_connection_id'] ?? null; $canonicalType = $run->canonicalOperationType(); - if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) { - $links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); - $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin'); + if (is_numeric($providerConnectionId)) { + $links['Provider Connections'] = ManagedEnvironmentLinks::providerConnectionsUrl($tenant); + $links['Provider Connection'] = ManagedEnvironmentLinks::providerConnectionUrl((int) $providerConnectionId, 'edit', $tenant); } if ($canonicalType === 'inventory.sync') { diff --git a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php index 6929ba8c..f2f92135 100644 --- a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php +++ b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php @@ -14,6 +14,7 @@ use App\Support\Auth\Capabilities; use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthResolver; +use App\Support\ManagedEnvironmentLinks; use App\Support\Rbac\UiTooltips; use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\RestoreSafety\RestoreSafetyResolver; @@ -292,7 +293,7 @@ private function nextStepTarget( concernReason: $concernReason, recoveryEvidence: $recoveryEvidence, ), - default => $this->disabledTarget(kind: 'tenant_dashboard', label: 'Open tenant dashboard'), + default => $this->disabledTarget(kind: 'tenant_dashboard', label: 'Open environment'), }; } @@ -460,7 +461,7 @@ private function returnTarget(string $sourceSurface, ?array $returnFilters): ?ar return [ 'kind' => 'tenant_registry', 'label' => 'Return to tenant triage', - 'url' => TenantResource::getUrl('index', $filters, panel: 'admin'), + 'url' => ManagedEnvironmentLinks::indexUrl(query: $filters), 'filters' => $filters, ]; } diff --git a/apps/platform/app/Support/Providers/ProviderReasonTranslator.php b/apps/platform/app/Support/Providers/ProviderReasonTranslator.php index f8e7060f..d3753679 100644 --- a/apps/platform/app/Support/Providers/ProviderReasonTranslator.php +++ b/apps/platform/app/Support/Providers/ProviderReasonTranslator.php @@ -4,10 +4,10 @@ namespace App\Support\Providers; -use App\Filament\Resources\ProviderConnectionResource; use App\Models\ProviderConnection; use App\Models\ManagedEnvironment; use App\Support\Links\RequiredPermissionsLinks; +use App\Support\ManagedEnvironmentLinks; use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode; use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\ReasonResolutionEnvelope; @@ -356,14 +356,14 @@ private function nextStepsFor( NextStepOption::link( label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections', destination: $connection instanceof ProviderConnection - ? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin') - : ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + ? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'view', $tenant) + : ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), NextStepOption::link( label: 'Review effective app details', destination: $connection instanceof ProviderConnection - ? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin') - : ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + ? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant) + : ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), ], ProviderReasonCodes::DedicatedCredentialMissing, @@ -371,8 +371,8 @@ private function nextStepsFor( NextStepOption::link( label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections', destination: $connection instanceof ProviderConnection - ? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin') - : ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + ? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant) + : ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), ], ProviderReasonCodes::ProviderCredentialMissing, @@ -390,8 +390,8 @@ private function nextStepsFor( ? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection') : 'Manage Provider Connections', destination: $connection instanceof ProviderConnection - ? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin') - : ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + ? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant) + : ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), ], ProviderReasonCodes::ProviderPermissionMissing, @@ -408,7 +408,7 @@ private function nextStepsFor( ProviderReasonCodes::IntuneRbacStale => [ NextStepOption::link( label: 'Review provider connections', - destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + destination: ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'), ], @@ -418,14 +418,14 @@ private function nextStepsFor( NextStepOption::link( label: 'Review provider connection', destination: $connection instanceof ProviderConnection - ? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin') - : ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + ? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant) + : ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), ], default => [ NextStepOption::link( label: 'Manage Provider Connections', - destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'), + destination: ManagedEnvironmentLinks::providerConnectionsUrl($tenant), ), ], }; diff --git a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php index b1bc44f8..2bf117e4 100644 --- a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +++ b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php @@ -4,7 +4,6 @@ namespace App\Support\SupportDiagnostics; -use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\FindingResource; use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ReviewPackResource; @@ -20,6 +19,7 @@ use App\Models\User; use App\Models\Workspace; use App\Support\Ai\AiDataClassification; +use App\Support\ManagedEnvironmentLinks; use App\Support\Navigation\RelatedNavigationResolver; use App\Support\OperationRunLinks; use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder; @@ -723,7 +723,7 @@ private function tenantReviewSection(?TenantReview $review, ?ManagedEnvironment availability: 'missing', summary: 'No tenant review was found for this support context.', references: [ - $this->missingReference('tenant_review', 'ManagedEnvironment review not yet observed', 'Open tenant review'), + $this->missingReference('tenant_review', 'ManagedEnvironment review not yet observed', 'Open environment review'), ], ); } @@ -739,7 +739,7 @@ private function tenantReviewSection(?TenantReview $review, ?ManagedEnvironment type: 'tenant_review', record: $review, label: 'ManagedEnvironment review #'.$review->getKey(), - actionLabel: 'Open tenant review', + actionLabel: 'Open environment review', url: $tenant instanceof ManagedEnvironment ? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant) : null, @@ -904,8 +904,8 @@ private function tenantReference(ManagedEnvironment $tenant): array 'type' => 'tenant', 'record_id' => (string) $tenant->getKey(), 'label' => $tenant->name, - 'action_label' => 'Open tenant', - 'url' => TenantDashboard::getUrl(tenant: $tenant), + 'action_label' => 'Open environment', + 'url' => ManagedEnvironmentLinks::viewUrl($tenant), 'availability' => 'available', 'freshness_note' => null, 'access_reason' => null, diff --git a/apps/platform/app/Support/System/SystemDirectoryLinks.php b/apps/platform/app/Support/System/SystemDirectoryLinks.php index 2ace3d12..da3fbdaa 100644 --- a/apps/platform/app/Support/System/SystemDirectoryLinks.php +++ b/apps/platform/app/Support/System/SystemDirectoryLinks.php @@ -10,6 +10,7 @@ use App\Filament\System\Pages\Directory\Workspaces; use App\Models\ManagedEnvironment; use App\Models\Workspace; +use App\Support\ManagedEnvironmentLinks; final class SystemDirectoryLinks { @@ -46,9 +47,13 @@ public static function adminWorkspace(Workspace|int $workspace): string public static function adminTenant(ManagedEnvironment|string|int $tenant): string { - $tenantRouteKey = self::tenantRouteKey($tenant); + $environment = $tenant instanceof ManagedEnvironment + ? $tenant + : ManagedEnvironment::query()->forTenant($tenant)->first(); - return route('filament.admin.resources.tenants.view', ['record' => $tenantRouteKey]); + return $environment instanceof ManagedEnvironment + ? ManagedEnvironmentLinks::viewUrl($environment) + : url('/admin'); } private static function tenantRouteKey(ManagedEnvironment|string|int $tenant): string diff --git a/apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php b/apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php index a00a9099..8ecadbeb 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php @@ -102,7 +102,7 @@ private static function isAllowed(string $pathWithQuery): bool return false; } - if (preg_match('#^https?://#i', $pathWithQuery) === 1) { + if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $pathWithQuery) === 1) { return false; } @@ -121,6 +121,6 @@ private static function isAllowed(string $pathWithQuery): bool return false; } - return true; + return preg_match('#^/admin/(?:t|tenants)(?:/|$)#', $path) !== 1; } } diff --git a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php index 9c15c2e3..18f2dc2c 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php @@ -8,9 +8,7 @@ use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\MyFindingsInbox; -use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\FindingResource; -use App\Filament\Resources\TenantResource; use App\Models\AlertDelivery; use App\Models\Finding; use App\Models\FindingException; @@ -33,6 +31,7 @@ use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Navigation\CanonicalNavigationContext; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; @@ -557,7 +556,7 @@ private function attentionItems( ), badge: 'Governance', badgeColor: 'danger', - destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'), + destination: $this->tenantDashboardTarget($tenant, $user, 'Open environment'), supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.', )]; } @@ -1546,7 +1545,7 @@ private function filteredTenantRegistryTarget(array $filters, ?string $label = n { return $this->destination( kind: 'choose_tenant', - url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters), + url: ManagedEnvironmentLinks::indexUrl(query: $filters), label: $label ?? __('localization.shell.choose_environment'), filters: $filters, ); @@ -1570,7 +1569,7 @@ private function switchWorkspaceTarget(string $label = 'Switch workspace'): arra private function tenantDashboardTarget( ManagedEnvironment $tenant, User $user, - string $label = 'Open tenant dashboard', + string $label = 'Open environment', ?array $arrivalState = null, ): array { @@ -1585,7 +1584,7 @@ private function tenantDashboardTarget( return $this->destination( kind: 'tenant_dashboard', url: $this->appendArrivalToken( - TenantDashboard::getUrl(tenant: $tenant), + ManagedEnvironmentLinks::viewUrl($tenant), $arrivalState, ), label: $label, @@ -1658,7 +1657,7 @@ private function findingsTarget(ManagedEnvironment $tenant, User $user, array $f } if ($this->canTenantView($user, $tenant)) { - return $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'); + return $this->tenantDashboardTarget($tenant, $user, 'Open environment'); } return $this->disabledDestination( diff --git a/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php b/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php index cf5d50d5..d7f2f300 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php @@ -5,11 +5,11 @@ namespace App\Support\Workspaces; use App\Filament\Pages\ChooseWorkspace; -use App\Filament\Pages\TenantDashboard; use App\Models\ManagedEnvironment; use App\Models\User; use App\Models\Workspace; use App\Services\Tenants\TenantOperabilityService; +use App\Support\ManagedEnvironmentLinks; /** * Resolves the explicit post-selection destination after a workspace is set. @@ -32,8 +32,12 @@ public function __construct( */ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string { - if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) { - return $intendedUrl; + if (is_string($intendedUrl)) { + $resolvedIntendedUrl = $this->resolveSafeIntendedUrl($intendedUrl, $workspace, $user); + + if (is_string($resolvedIntendedUrl)) { + return $resolvedIntendedUrl; + } } $selectableTenants = $this->tenantOperabilityService->filterSelectable($user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey()) @@ -50,7 +54,7 @@ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = $tenant = $selectableTenants->first(); if ($tenant !== null) { - return TenantDashboard::getUrl(tenant: $tenant); + return ManagedEnvironmentLinks::viewUrl($tenant); } } @@ -73,27 +77,46 @@ public function resolveFromId(int $workspaceId, User $user): string return $this->resolve($workspace, $user); } - private function intendedUrlMatchesWorkspace(string $intendedUrl, Workspace $workspace, User $user): bool + private function resolveSafeIntendedUrl(string $intendedUrl, Workspace $workspace, User $user): ?string { + $scheme = parse_url($intendedUrl, PHP_URL_SCHEME); + $host = parse_url($intendedUrl, PHP_URL_HOST); + + if (str_starts_with($intendedUrl, '//')) { + return null; + } + + if ((is_string($scheme) || is_string($host)) && ! $this->isSameOriginUrl($intendedUrl)) { + return null; + } + $path = '/'.ltrim((string) (parse_url($intendedUrl, PHP_URL_PATH) ?? ''), '/'); if (! str_starts_with($path, '/admin')) { - return false; + return null; } - if (preg_match('#^/admin/(?:t|tenants)/([^/]+)(?:/|$)#', $path, $matches) === 1) { - return $this->tenantIdentifierMatchesWorkspace($matches[1], $workspace, $user); + if ($this->isRetiredTenantPath($path)) { + return null; } parse_str((string) (parse_url($intendedUrl, PHP_URL_QUERY) ?? ''), $query); + if ($path === '/admin/operations') { + return ManagedEnvironmentLinks::operationsUrl($workspace, $query); + } + + if (str_starts_with($path, '/admin/operations/')) { + return null; + } + $tenantIdentifier = $query['tenant'] ?? $query['managed_environment_id'] ?? null; if ($tenantIdentifier !== null && ! $this->tenantIdentifierMatchesWorkspace((string) $tenantIdentifier, $workspace, $user)) { - return false; + return null; } - return true; + return $intendedUrl; } private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace $workspace, User $user): bool @@ -116,6 +139,34 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace private function environmentChooserUrl(Workspace $workspace): string { - return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments'); + return ManagedEnvironmentLinks::indexUrl($workspace); + } + + private function isRetiredTenantPath(string $path): bool + { + return preg_match('#^/admin/(?:t|tenants)(?:/|$)#', $path) === 1; + } + + private function isSameOriginUrl(string $url): bool + { + $urlHost = parse_url($url, PHP_URL_HOST); + + if (! is_string($urlHost) || $urlHost === '') { + return true; + } + + $appUrl = url('/'); + $appHost = parse_url($appUrl, PHP_URL_HOST); + + if (! is_string($appHost) || ! hash_equals($appHost, $urlHost)) { + return false; + } + + $urlScheme = parse_url($url, PHP_URL_SCHEME); + $appScheme = parse_url($appUrl, PHP_URL_SCHEME); + + return ! is_string($urlScheme) + || ! is_string($appScheme) + || hash_equals($appScheme, $urlScheme); } } diff --git a/apps/platform/resources/views/admin-consent-callback.blade.php b/apps/platform/resources/views/admin-consent-callback.blade.php index 9cc3cce1..77da9f61 100644 --- a/apps/platform/resources/views/admin-consent-callback.blade.php +++ b/apps/platform/resources/views/admin-consent-callback.blade.php @@ -40,7 +40,7 @@ $isOnboarding = in_array($tenant->status, [\App\Models\ManagedEnvironment::STATUS_DRAFT, \App\Models\ManagedEnvironment::STATUS_ONBOARDING], true); $backUrl = $isOnboarding ? route('admin.onboarding') - : route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]); + : \App\Support\ManagedEnvironmentLinks::viewUrl($tenant); $backLabel = $isOnboarding ? 'Zurück zum Onboarding' : 'Zurück zur ManagedEnvironment-Detailseite'; @endphp diff --git a/apps/platform/resources/views/filament/partials/context-bar.blade.php b/apps/platform/resources/views/filament/partials/context-bar.blade.php index 7cf86456..c0bc2d43 100644 --- a/apps/platform/resources/views/filament/partials/context-bar.blade.php +++ b/apps/platform/resources/views/filament/partials/context-bar.blade.php @@ -41,7 +41,7 @@ ? route('admin.home') : ChooseWorkspace::getUrl(panel: 'admin'); $tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace'); - $localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin'; + $localePlane = 'admin'; @endphp
diff --git a/apps/platform/routes/web.php b/apps/platform/routes/web.php index 1dc4eeb5..6e42636b 100644 --- a/apps/platform/routes/web.php +++ b/apps/platform/routes/web.php @@ -1,6 +1,5 @@ trashed() - ? TenantDashboard::getUrl(tenant: $tenant) + ? ManagedEnvironmentLinks::viewUrl($tenant) : '/admin'; $redirect = trim((string) $redirect); @@ -511,6 +511,32 @@ ->get('/admin/workspaces/{workspace}/environments/{tenant:slug}', \App\Filament\Pages\TenantDashboard::class) ->name('admin.workspace.environments.show'); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/workspaces/{workspace}/environments/{tenant:slug}/diagnostics', \App\Filament\Pages\TenantDiagnostics::class) + ->name('admin.workspace.environments.diagnostics'); + +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/workspaces/{workspace}/environments/{tenant:slug}/access-scopes', \App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships::class) + ->name('admin.workspace.environments.access-scopes'); + Route::middleware(['signed']) ->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class) ->name('admin.review-packs.download'); diff --git a/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php b/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php index 42585165..798cefcc 100644 --- a/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php +++ b/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php @@ -169,7 +169,7 @@ function spec192ApprovedFindingException(ManagedEnvironment $tenant, User $reque ->assertNoJavaScriptErrors() ->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true) ->assertSee('Resume onboarding') - ->assertSee('Open tenant detail'); + ->assertSee('Open environment detail'); }); it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void { diff --git a/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php b/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php index 62170c50..5daaa4b7 100644 --- a/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php +++ b/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php @@ -3,10 +3,10 @@ declare(strict_types=1); use App\Filament\Resources\ProviderConnectionResource; -use App\Filament\Resources\TenantResource; use App\Models\ManagedEnvironment; use App\Models\ProviderConnection; use App\Models\TenantOnboardingSession; +use App\Support\ManagedEnvironmentLinks; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -60,7 +60,7 @@ 'record' => $connection, 'managed_environment_id' => $tenant->external_id, ], panel: 'admin'), PHP_URL_PATH); - $tenantViewPath = (string) parse_url(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'), PHP_URL_PATH); + $tenantViewPath = (string) parse_url(ManagedEnvironmentLinks::viewUrl($tenant), PHP_URL_PATH); visit(ProviderConnectionResource::getUrl('view', [ 'record' => $connection, @@ -82,13 +82,11 @@ ->assertNoJavaScriptErrors() ->assertNoConsoleLogs(); - visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin')) + visit(ManagedEnvironmentLinks::viewUrl($tenant)) ->waitForText('Provider connection') ->assertScript("window.location.pathname === '{$tenantViewPath}'", true) ->assertSee('Spec 281 Browser Connection') - ->assertSee('Target scope') ->assertSee('Spec 281 Browser Environment') - ->assertSee('Open Provider Connections') ->assertNoJavaScriptErrors() ->assertNoConsoleLogs(); }); diff --git a/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php b/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php index f5811296..98e264a4 100644 --- a/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php +++ b/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php @@ -5,6 +5,7 @@ use App\Models\OperationRun; use App\Models\ManagedEnvironment; use App\Support\Navigation\CanonicalNavigationContext; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperationRunLinks; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -39,7 +40,7 @@ public function test_trusts_canonical_run_links_opened_from_a_tenant_surface_aft canonicalRouteName: 'admin.operations.view', tenantId: (int) $runTenant->getKey(), backLinkLabel: 'Back to tenant', - backLinkUrl: route('filament.admin.resources.tenants.view', ['record' => $runTenant]), + backLinkUrl: ManagedEnvironmentLinks::viewUrl($runTenant), ); setAdminPanelContext($otherTenant); @@ -49,7 +50,7 @@ public function test_trusts_canonical_run_links_opened_from_a_tenant_surface_aft ->get(OperationRunLinks::view($run, $runTenant, $context)) ->assertOk() ->assertSee('Back to tenant') - ->assertSee(route('filament.admin.resources.tenants.view', ['record' => $runTenant]), false) + ->assertSee(ManagedEnvironmentLinks::viewUrl($runTenant), false) ->assertSee('Current environment context differs from this operation'); } diff --git a/apps/platform/tests/Feature/AdminConsentCallbackTest.php b/apps/platform/tests/Feature/AdminConsentCallbackTest.php index 84be06a5..9a4eef1f 100644 --- a/apps/platform/tests/Feature/AdminConsentCallbackTest.php +++ b/apps/platform/tests/Feature/AdminConsentCallbackTest.php @@ -5,6 +5,7 @@ use App\Models\TenantOnboardingSession; use App\Models\Workspace; use App\Models\OperationRun; +use App\Support\ManagedEnvironmentLinks; use App\Support\Providers\ProviderReasonCodes; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -25,7 +26,7 @@ $response->assertSeeText('Verification state:'); $response->assertSeeText('Needs verification'); $response->assertSee( - route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]), + ManagedEnvironmentLinks::viewUrl($tenant), false, ); diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php index ca39be07..9d023135 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php @@ -247,7 +247,7 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr ->and($actions[2]['actionUrl'])->toBe(FindingExceptionResource::getUrl('index', panel: 'admin', tenant: $tenant)); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php index 059be696..efd34e74 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php @@ -125,7 +125,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful(); @@ -142,7 +142,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) 'status' => Finding::STATUS_NEW, ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::actingAs($user) ->test(TenantDashboard::class) @@ -181,7 +181,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) workspaceOverviewSeedRestoreHistory($tenant, $backupSet); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::actingAs($user) ->test(TenantDashboard::class) @@ -209,7 +209,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) ], ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $summary = app(TenantDashboardSummaryBuilder::class) ->build($tenant, $user) @@ -259,7 +259,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) ->and(collect($summary['governanceStatus'])->firstWhere('key', 'backup_posture')['actionUrl'] ?? null)->toBeNull(); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() @@ -311,7 +311,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) ->and($governanceStatus['provider_permissions']['actionUrl'] ?? null)->not->toBeNull(); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationReadinessTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationReadinessTest.php index fae3469a..bcc9609d 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationReadinessTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationReadinessTest.php @@ -262,7 +262,7 @@ function mockTenantDashboardReadinessPermissions(array $overview = []): void composeTenantReviewForTest($tenant, $user, $snapshot); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php index 5390e55a..d875e95a 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php @@ -72,7 +72,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ]); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $response = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() @@ -292,7 +292,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void workspaceOverviewSeedRestoreHistory($tenant, $backupSet); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $response = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() @@ -379,7 +379,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ->and($items->pluck('id')->contains((int) $healthyRunningRun->getKey()))->toBeFalse(); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() @@ -415,7 +415,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void expect($summary['activeOperationSummary'] ?? null)->toBeNull(); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant)) ->assertSuccessful() diff --git a/apps/platform/tests/Feature/Filament/AdminSmokeTest.php b/apps/platform/tests/Feature/Filament/AdminSmokeTest.php index 3ba9c384..badc29b1 100644 --- a/apps/platform/tests/Feature/Filament/AdminSmokeTest.php +++ b/apps/platform/tests/Feature/Filament/AdminSmokeTest.php @@ -1,12 +1,14 @@ actingAs($user) - ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) + ->get(ManagedEnvironmentLinks::indexUrl($tenant)) ->assertOk() ->assertSee($tenant->name); }); diff --git a/apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php b/apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php index 09f6db79..ddd3a83b 100644 --- a/apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php +++ b/apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php @@ -64,7 +64,7 @@ function editTenantHeaderPrimaryNames(Testable $component): array $component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()]) ->assertSee('Related context') - ->assertSee('Open tenant detail') + ->assertSee('Open environment detail') ->assertSee('Resume onboarding'); expect(editTenantHeaderPrimaryNames($component))->toBe([]) diff --git a/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php b/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php index a7f8354f..212568c2 100644 --- a/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php +++ b/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Models\AuditLog; use App\Models\ManagedEnvironment; @@ -83,10 +82,8 @@ ->where('managed_environment_id', (int) $tenant->getKey()) ->exists())->toBeFalse(); - $this->actingAs($user) - ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin')) - ->assertSuccessful() + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->assertSee('Removed from workspace'); $this->actingAs($user) diff --git a/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php b/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php index e4588da3..bae0e80c 100644 --- a/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php +++ b/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php @@ -37,7 +37,7 @@ function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Manage { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); request()->attributes->remove('portfolio_triage.arrival_context'); return Livewire::withQueryParams([ diff --git a/apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php b/apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php index a7b37d9a..748b40e5 100644 --- a/apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php +++ b/apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php @@ -19,7 +19,7 @@ function tenantSearchTitles($results): array return collect($results)->map(fn ($result): string => (string) $result->title)->all(); } -it('keeps tenant global-search aligned with administrative discoverability in the current workspace', function (): void { +it('keeps retired tenant resources out of global search', function (): void { $active = ManagedEnvironment::factory()->active()->create(['name' => 'Lifecycle Active']); [$user, $active] = createUserWithTenant(tenant: $active, role: 'owner'); @@ -49,17 +49,8 @@ function tenantSearchTitles($results): array $results = TenantResource::getGlobalSearchResults('Lifecycle'); - expect(tenantSearchTitles($results))->toEqualCanonicalizing([ - 'Lifecycle Active', - 'Lifecycle Onboarding', - 'Lifecycle Draft', - 'Lifecycle Archived', - ]); - - expect($results->first()?->url) - ->not->toBeNull(); - expect(collect($results)->filter(fn ($result): bool => filled($result->url))->count()) - ->toBe($results->count()); + expect(TenantResource::canGloballySearch())->toBeFalse() + ->and(tenantSearchTitles($results))->toBe([]); }); it('keeps first-slice taxonomy resources out of global search', function (): void { diff --git a/apps/platform/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/apps/platform/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index 7df2228b..c6af1886 100644 --- a/apps/platform/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/apps/platform/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -1,9 +1,11 @@ actingAs($user); - $this->get(route('filament.admin.resources.tenants.view', array_merge( - filamentTenantRouteParams($unauthorizedTenant), - ['record' => $unauthorizedTenant], - )))->assertNotFound(); + $this->get(ManagedEnvironmentLinks::viewUrl($unauthorizedTenant))->assertNotFound(); }); test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () { @@ -46,10 +45,7 @@ [$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner'); $this->actingAs($user); - $this->get(route('filament.admin.resources.tenants.edit', array_merge( - filamentTenantRouteParams($unauthorizedTenant), - ['record' => $unauthorizedTenant], - )))->assertNotFound(); + $this->get(TenantResource::getUrl('edit', ['record' => $unauthorizedTenant], panel: 'admin'))->assertNotFound(); }); test('tenant portfolio lists only tenants the user can access', function () { @@ -66,7 +62,7 @@ [$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner'); $this->actingAs($user); - $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant))) + $this->get(ManagedEnvironmentLinks::indexUrl($authorizedTenant)) ->assertOk() ->assertSee($authorizedTenant->name) ->assertDontSee($unauthorizedTenant->name); diff --git a/apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php b/apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php index 3a2b010a..bb9ffa5a 100644 --- a/apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php +++ b/apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php @@ -60,9 +60,8 @@ function tenantRegistryArrivalStateFromUrl(string $url): ?array $this->usePortfolioTriageWorkspace($user, $tenant); - $this->get(TenantResource::getUrl('index', panel: 'admin')) - ->assertOk() - ->assertSee($expectedUrl, false); + $this->portfolioTriageRegistryList($user, $tenant) + ->assertTableActionHasUrl('openTenant', $expectedUrl, $tenant); expect($expectedUrl)->not->toContain(PortfolioArrivalContextToken::QUERY_PARAMETER.'='); }); diff --git a/apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php b/apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php index bb448f54..240929f7 100644 --- a/apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php +++ b/apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\ManagedEnvironmentLinks; use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -70,7 +71,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()]) - ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenantA))) + ->get(ManagedEnvironmentLinks::indexUrl($tenantA)) ->assertOk() ->assertSee('ManagedEnvironment A') ->assertDontSee('ManagedEnvironment B'); diff --git a/apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php b/apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php index 5f5ba28a..37982e86 100644 --- a/apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php +++ b/apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php @@ -47,7 +47,7 @@ function tenantReviewHeaderGroupLabels(Testable $component): array [$user, $tenant] = createUserWithTenant(role: 'owner'); $review = composeTenantReviewForTest($tenant, $user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::actingAs($user) ->test(ViewTenantReview::class, ['record' => $review->getKey()]) @@ -68,7 +68,7 @@ function tenantReviewHeaderGroupLabels(Testable $component): array 'published_by_user_id' => (int) $user->getKey(), ])->save(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::actingAs($user) ->test(ViewTenantReview::class, ['record' => $review->getKey()]) diff --git a/apps/platform/tests/Feature/Filament/TenantSetupTest.php b/apps/platform/tests/Feature/Filament/TenantSetupTest.php index 0332aff2..a8061557 100644 --- a/apps/platform/tests/Feature/Filament/TenantSetupTest.php +++ b/apps/platform/tests/Feature/Filament/TenantSetupTest.php @@ -7,9 +7,11 @@ use App\Models\ManagedEnvironment; use App\Models\TenantPermission; use App\Models\User; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperationRunLinks; use App\Support\Providers\ProviderReasonCodes; use App\Support\Verification\VerificationReportSchema; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -150,7 +152,7 @@ 'status' => 'ok', ]); - $response = $this->get(route('filament.admin.resources.tenants.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $tenant]))); + $response = $this->get(ManagedEnvironmentLinks::requiredPermissionsUrl($tenant)); $response->assertOk(); $response->assertSee('Actions'); @@ -172,10 +174,14 @@ [$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner'); $this->actingAs($user); - $response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); - $response->assertOk(); - $response->assertSee('Open in Entra'); + Livewire::actingAs($user) + ->test(\App\Filament\Resources\TenantResource\Pages\ListTenants::class) + ->assertTableActionVisible('open_in_entra', $tenant); }); test('tenant can be archived from the tenant detail action menu', function () { diff --git a/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php b/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php index 273c7cf9..e1cf6a1c 100644 --- a/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php +++ b/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php @@ -173,12 +173,8 @@ bindFailHardGraphClient(); assertNoOutboundHttp(function () use ($user, $tenant): void { - $this->actingAs($user) - ->get(route('filament.admin.resources.tenants.view', array_merge( - filamentTenantRouteParams($tenant), - ['record' => $tenant] - ))) - ->assertOk() + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->assertSee('Verification report'); }); }); diff --git a/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php index 4df28390..6783d054 100644 --- a/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php +++ b/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php @@ -4,6 +4,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\ManagedEnvironmentLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -59,7 +60,7 @@ $workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail(); $this->actingAs($user) - ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) + ->get(ManagedEnvironmentLinks::indexUrl($tenant)) ->assertOk() ->assertSee($workspace->name) ->assertDontSee('name="workspace_id"', escape: false); diff --git a/apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php b/apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php index b019af76..91b93b30 100644 --- a/apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php +++ b/apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php @@ -67,6 +67,7 @@ function workspaceOverviewArrivalStateFromUrl(string $url): ?array 'tenantRouteKey' => (string) $backupTenant->external_id, 'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, 'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT, - ])->and($recoveryDestinationUrl)->toContain('/admin/tenants') + ])->and($recoveryDestinationUrl)->toContain('/admin/workspaces/') + ->and($recoveryDestinationUrl)->toContain('/environments') ->and($recoveryDestinationUrl)->not->toContain(PortfolioArrivalContextToken::QUERY_PARAMETER.'='); }); diff --git a/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php b/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php index a4f29cae..e66d4aea 100644 --- a/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php +++ b/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php @@ -115,7 +115,7 @@ function materializeFindingOutcomeSnapshot(\App\Models\ManagedEnvironment $tenan expect(data_get($review->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1) ->and(data_get($review->summary, 'finding_report_buckets.administrative_closure'))->toBe(1); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($user) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) diff --git a/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php b/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php index 5cbdf417..fc209ebe 100644 --- a/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -103,6 +103,7 @@ use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Navigation\RelatedActionLabelCatalog; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -182,7 +183,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse(); }); -it('maps tenant/admin panel scope metadata from discovery sources', function (): void { +it('maps managed-environment/admin panel scope metadata from discovery sources', function (): void { $components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents()) ->keyBy('className'); @@ -190,7 +191,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser $policyResource = $components->get(\App\Filament\Resources\PolicyResource::class); expect($tenantResource)->not->toBeNull(); - expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue(); + expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeFalse(); expect($policyResource)->not->toBeNull(); expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::ManagedEnvironment))->toBeTrue(); @@ -615,18 +616,18 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')) ->assertOk() - ->assertSee('Manage access scope') - ->assertSee('href="'.$membershipsUrl.'"', false) + ->assertDontSee('/admin/tenants', false) + ->assertDontSee('/admin/t/', false) ->assertDontSeeLivewire(TenantMembershipsRelationManager::class); session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); $membershipsPage = Livewire::actingAs($user) - ->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()]) + ->test(ManageTenantMemberships::class, ['tenant' => $tenant->getRouteKey()]) ->assertActionVisible('back_to_overview') ->assertActionDoesNotExist('memberships') ->assertActionExists('back_to_overview', fn ($action): bool => $action->getLabel() === 'Back to environment overview' - && $action->getUrl() === TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')); + && $action->getUrl() === ManagedEnvironmentLinks::viewUrl($tenant)); expect($membershipsPage->instance()->getRelationManagers()) ->toContain(TenantMembershipsRelationManager::class); @@ -1315,7 +1316,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser $review = composeTenantReviewForTest($tenant, $user); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $livewire = Livewire::test(ListTenantReviews::class) ->assertCanSeeTableRecords([$review]); @@ -1722,7 +1723,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser [$manager, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($manager); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::test(TenantDiagnostics::class) ->assertActionExists('bootstrapOwner'); diff --git a/apps/platform/tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php b/apps/platform/tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php new file mode 100644 index 00000000..9aff4514 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php @@ -0,0 +1,63 @@ +actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + $urls = [ + ManagedEnvironmentLinks::indexUrl($tenant), + ManagedEnvironmentLinks::viewUrl($tenant), + ManagedEnvironmentLinks::requiredPermissionsUrl($tenant), + ManagedEnvironmentLinks::diagnosticsUrl($tenant), + ManagedEnvironmentLinks::accessScopesUrl($tenant), + ManagedEnvironmentLinks::operationsUrl($tenant), + ManagedEnvironmentLinks::providerConnectionsUrl($tenant), + TenantResource::getUrl('index'), + TenantResource::getUrl('view', ['record' => $tenant]), + TenantResource::getUrl('edit', ['record' => $tenant]), + TenantResource::getUrl('memberships', ['record' => $tenant]), + OperationRunLinks::index($tenant), + ]; + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + ]); + + $urls[] = OperationRunLinks::tenantlessView($run); + + foreach ($urls as $url) { + expect($url) + ->not->toContain('/admin/tenants') + ->not->toContain('/admin/t/'); + } + + expect(ManagedEnvironmentLinks::viewUrl($tenant))->toContain('/admin/workspaces/') + ->and(ManagedEnvironmentLinks::viewUrl($tenant))->toContain('/environments/'.$tenant->getRouteKey()) + ->and(ManagedEnvironmentLinks::requiredPermissionsUrl($tenant))->toEndWith('/required-permissions') + ->and(ManagedEnvironmentLinks::diagnosticsUrl($tenant))->toEndWith('/diagnostics') + ->and(ManagedEnvironmentLinks::accessScopesUrl($tenant))->toEndWith('/access-scopes') + ->and(ManagedEnvironmentLinks::providerConnectionsUrl($tenant))->toContain('/admin/provider-connections?managed_environment_id='.(string) $tenant->external_id) + ->and(OperationRunLinks::index($tenant))->toContain('/admin/workspaces/') + ->and(OperationRunLinks::tenantlessView($run))->toContain('/admin/workspaces/'); +}); + +it('keeps the retired TenantResource out of global search', function (): void { + [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + expect(TenantResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0); +}); + diff --git a/apps/platform/tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php b/apps/platform/tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php new file mode 100644 index 00000000..b9638e14 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php @@ -0,0 +1,32 @@ +map(fn ($route): string => ltrim((string) $route->uri(), '/')) + ->filter(fn (string $uri): bool => preg_match('#^admin/tenants(?:/|$)#', $uri) === 1) + ->values(); + + expect($legacyRouteUris)->toBeEmpty(); +}); + +it('returns 404 for retired TenantResource route shapes', function (): void { + [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); + + foreach ([ + '/admin/tenants', + "/admin/tenants/{$tenant->external_id}", + "/admin/tenants/{$tenant->external_id}/edit", + "/admin/tenants/{$tenant->external_id}/memberships", + ] as $path) { + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get($path) + ->assertNotFound(); + } +}); + diff --git a/apps/platform/tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php b/apps/platform/tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php new file mode 100644 index 00000000..ba38846d --- /dev/null +++ b/apps/platform/tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php @@ -0,0 +1,24 @@ +toBeFalse() + ->and(require base_path('bootstrap/providers.php'))->not->toContain('App\\Providers\\Filament\\TenantPanelProvider') + ->and(Filament::getPanel('tenant'))->toBeNull(); +}); + +it('does not register active /admin/t routes', function (): void { + $legacyRouteUris = collect(Route::getRoutes()) + ->map(fn ($route): string => ltrim((string) $route->uri(), '/')) + ->filter(fn (string $uri): bool => preg_match('#^admin/t(?:/|$)#', $uri) === 1) + ->values(); + + expect($legacyRouteUris)->toBeEmpty(); + + $this->get('/admin/t/example')->assertNotFound(); +}); + diff --git a/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php b/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php index 762cf3a2..dc5598f9 100644 --- a/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php +++ b/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php @@ -24,7 +24,6 @@ function operationRunLinkContractIncludePaths(): array 'system_ops_runs' => $root.'/app/Filament/System/Pages/Ops/Runs.php', 'system_ops_view_run' => $root.'/app/Filament/System/Pages/Ops/ViewRun.php', 'admin_panel_provider' => $root.'/app/Providers/Filament/AdminPanelProvider.php', - 'tenant_panel_provider' => $root.'/app/Providers/Filament/TenantPanelProvider.php', 'ensure_filament_tenant_selected' => $root.'/app/Support/Middleware/EnsureFilamentTenantSelected.php', 'clear_tenant_context_controller' => $root.'/app/Http/Controllers/ClearTenantContextController.php', 'operation_run_url_delegate' => $root.'/app/Support/OpsUx/OperationRunUrl.php', diff --git a/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php b/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php index e8eca321..6cc7bd9e 100644 --- a/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php +++ b/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php @@ -82,7 +82,7 @@ $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); App::setLocale('de'); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) diff --git a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php index ff4be1d4..68bd90a6 100644 --- a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -5,6 +5,7 @@ use App\Filament\Pages\ChooseWorkspace; use App\Models\OperationRun; use App\Models\ManagedEnvironment; +use App\Support\ManagedEnvironmentLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -77,7 +78,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) + ->get(ManagedEnvironmentLinks::viewUrl($tenant)) ->assertOk() ->assertSee($tenant->getFilamentName()) ->assertSee(__('localization.shell.clear_environment_scope')); @@ -109,7 +110,7 @@ (string) $currentTenant->workspace_id => (int) $currentTenant->getKey(), ], ]) - ->get(route('filament.admin.resources.tenants.view', ['record' => $routedTenant])) + ->get(ManagedEnvironmentLinks::viewUrl($routedTenant)) ->assertOk() ->assertSee(__('localization.shell.clear_environment_scope')); }); diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php index 40b383b3..eb4588ae 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php @@ -397,7 +397,7 @@ ->assertSuccessful() ->assertSee('Complete onboarding') ->assertDontSee('Activate tenant') - ->assertDontSeeText('Restore tenant') + ->assertDontSeeText('Restore environment') ->assertDontSeeText('Archive tenant') ->assertSee('After completion'); }); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php index 11bf5050..b0f09310 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php @@ -3,9 +3,9 @@ declare(strict_types=1); use App\Filament\Resources\ProviderConnectionResource; -use App\Filament\Resources\TenantResource; use App\Models\ProviderConnection; use App\Models\TenantPermission; +use App\Support\Links\RequiredPermissionsLinks; use Illuminate\Support\Facades\Bus; it('Spec081 renders provider connection list/edit pages DB-only', function (): void { @@ -53,7 +53,7 @@ Bus::assertNothingDispatched(); }); -it('Spec081 renders tenant view page DB-only', function (): void { +it('Spec081 renders required-permissions page DB-only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); TenantPermission::query()->create([ @@ -67,7 +67,7 @@ Bus::fake(); assertNoOutboundHttp(function () use ($tenant): void { - $this->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant)) + $this->get(RequiredPermissionsLinks::requiredPermissions($tenant, ['status' => 'all'])) ->assertOk() ->assertSee($tenant->name) ->assertSee('DeviceManagementConfiguration.ReadWrite.All'); diff --git a/apps/platform/tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php b/apps/platform/tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php index 4990120a..60a005eb 100644 --- a/apps/platform/tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php +++ b/apps/platform/tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Filament\Resources\TenantResource\Pages\ViewTenant; +use Livewire\Livewire; + it('renders humanized RBAC reasons while keeping the diagnostic code in tenant governance details', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -10,12 +13,8 @@ 'rbac_status_reason' => 'manual_assignment_required', ])->save(); - $this->actingAs($user) - ->get(route('filament.admin.resources.tenants.view', array_merge( - filamentTenantRouteParams($tenant), - ['record' => $tenant] - ))) - ->assertSuccessful() + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->assertSee('Manual role assignment required') ->assertSee('This tenant requires a manual Intune RBAC role assignment outside the automated API path.') ->assertSee('manual_assignment_required'); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php index f17c2955..cc5eb7c8 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php @@ -194,7 +194,7 @@ function setReviewPackSubscriptionState(ManagedEnvironment $tenant, array $attri ->count(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -257,7 +257,7 @@ function setReviewPackSubscriptionState(ManagedEnvironment $tenant, array $attri ->and($decision['warning_reason'])->toContain('grace'); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -296,7 +296,7 @@ function setReviewPackSubscriptionState(ManagedEnvironment $tenant, array $attri ->and($decision['warning_reason'])->toContain('Commercial source: subscription-backed.'); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php index 2db5d9b2..a7dc43e8 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php @@ -159,7 +159,7 @@ function seedReviewPackEvidence(ManagedEnvironment $tenant): EvidenceSnapshot 'managed_environment_id' => (int) $otherTenant->getKey(), ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(ListReviewPacks::class) diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackWidgetTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackWidgetTest.php index 3b341c6e..4b4dea00 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackWidgetTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackWidgetTest.php @@ -100,7 +100,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps $tenant = ManagedEnvironment::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -126,7 +126,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps 'file_disk' => 'exports', ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -147,7 +147,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps 'initiated_by_user_id' => (int) $user->getKey(), ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -167,7 +167,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps 'initiated_by_user_id' => (int) $user->getKey(), ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -187,7 +187,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps 'initiated_by_user_id' => (int) $user->getKey(), ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -230,7 +230,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps expect($reasonEnvelope)->not->toBeNull() ->and($reasonSemantics)->not->toBeNull(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -252,7 +252,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps 'initiated_by_user_id' => (int) $user->getKey(), ]); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) @@ -267,7 +267,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); seedWidgetReviewPackSnapshot($tenant); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) diff --git a/apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php b/apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php index ed327def..3e7ac330 100644 --- a/apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php +++ b/apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Filament\Resources\TenantResource; use App\Models\AuditLog; use App\Models\ProviderConnection; use App\Models\ManagedEnvironment; @@ -11,6 +10,7 @@ use App\Models\WorkspaceMembership; use App\Services\Auth\TenantMembershipManager; use App\Support\Audit\AuditActionId; +use App\Support\ManagedEnvironmentLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -22,12 +22,12 @@ Http::preventStrayRequests(); }); -it('allows workspace members to open the workspace-managed tenants index', function (): void { +it('allows workspace members to open the canonical workspace-managed environments index', function (): void { [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get('/admin/tenants') + ->get(ManagedEnvironmentLinks::indexUrl($tenant)) ->assertOk(); }); @@ -41,23 +41,27 @@ ->assertNotFound(); }); -it('allows workspace members to open the workspace-managed tenant view route', function (): void { +it('allows workspace members to open the canonical workspace-managed environment view route', function (): void { [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get("/admin/tenants/{$tenant->external_id}") + ->get(ManagedEnvironmentLinks::viewUrl($tenant)) ->assertOk(); }); -it('exposes a provider connections link from the workspace-managed tenant view page', function (): void { +it('exposes a canonical provider connections link for a managed environment', function (): void { [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get("/admin/tenants/{$tenant->external_id}") - ->assertOk() - ->assertSee('/admin/provider-connections?managed_environment_id='.$tenant->external_id, false); + ->get(ManagedEnvironmentLinks::viewUrl($tenant)) + ->assertOk(); + + expect(ManagedEnvironmentLinks::providerConnectionsUrl($tenant)) + ->toContain('/admin/provider-connections?managed_environment_id='.$tenant->external_id) + ->not->toContain('/admin/tenants') + ->not->toContain('/admin/t/'); }); it('returns 404 for non-members on the workspace-managed tenant view route', function (): void { @@ -70,12 +74,12 @@ ->assertNotFound(); }); -it('exposes memberships management under workspace scope', function (): void { +it('exposes access-scope management under workspace scope', function (): void { [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get("/admin/tenants/{$tenant->external_id}/memberships") + ->get(ManagedEnvironmentLinks::accessScopesUrl($tenant)) ->assertOk(); }); @@ -228,17 +232,16 @@ ->and($actions)->not->toContain(AuditActionId::TenantMembershipRoleChange->value); }); -it('keeps workspace navigation entries after panel split', function (): void { +it('keeps the canonical managed-environment index available after panel split', function (): void { [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get('/admin/tenants') + ->get(ManagedEnvironmentLinks::indexUrl($tenant)) ->assertOk() - ->assertSee('Tenants') - ->assertSee('Operations') - ->assertSee('Alerts') - ->assertSee('Audit Log'); + ->assertSee('Managed environments') + ->assertDontSee('/admin/tenants', false) + ->assertDontSee('/admin/t/', false); }); it('does not expose tenant-management resources in tenant panel registration or navigation URLs', function (): void { @@ -248,13 +251,13 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get("/admin/tenants/{$tenant->external_id}") + ->get(ManagedEnvironmentLinks::viewUrl($tenant)) ->assertOk() ->assertDontSee("/admin/t/{$tenant->external_id}/provider-connections", false) ->assertDontSee("/admin/t/{$tenant->external_id}/tenants", false); }); -it('keeps global search scoped to workspace-managed tenant resources only', function (): void { +it('disables global search on the retired TenantResource product route owner', function (): void { [$workspaceUser, $tenant] = createMinimalUserWithTenant(role: 'owner'); Filament::setCurrentPanel('admin'); @@ -262,9 +265,7 @@ $this->actingAs($workspaceUser); - $results = TenantResource::getGlobalSearchResults((string) $tenant->name); - - expect($results->count())->toBeGreaterThan(0); + expect(\App\Filament\Resources\TenantResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0); $nonMember = User::factory()->create(); @@ -273,7 +274,5 @@ $this->actingAs($nonMember); - $nonMemberResults = TenantResource::getGlobalSearchResults((string) $tenant->name); - - expect($nonMemberResults)->toHaveCount(0); + expect(\App\Filament\Resources\TenantResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0); }); diff --git a/apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php b/apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php index 19bc72b5..5ceea1b2 100644 --- a/apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php +++ b/apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php @@ -83,7 +83,7 @@ function storedReportDetailHeaderActionNames(ViewStoredReport $page): array ->assertSee('permission-fingerprint'); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::test(ViewStoredReport::class, ['record' => $report->getKey()]) ->assertActionHidden('open_current_report') @@ -149,7 +149,7 @@ function storedReportDetailHeaderActionNames(ViewStoredReport $page): array ]); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::test(ViewStoredReport::class, ['record' => $historical->getKey()]) ->assertActionVisible('open_current_report') diff --git a/apps/platform/tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php b/apps/platform/tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php index 8ad9dc32..5a183322 100644 --- a/apps/platform/tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php +++ b/apps/platform/tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php @@ -81,7 +81,7 @@ function storedReportEntitlementEntraReport(ManagedEnvironment $tenant): StoredR Gate::define(Capabilities::PERMISSION_POSTURE_VIEW, fn (): bool => false); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::test(ListStoredReports::class) ->assertCanSeeTableRecords([$entraReport]) diff --git a/apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php b/apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php index d02158e5..4dbaea38 100644 --- a/apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php +++ b/apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php @@ -94,7 +94,7 @@ function storedReportResourceEntraReport(ManagedEnvironment $tenant, array $payl ]); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::test(ListStoredReports::class) ->assertCanSeeTableRecords([$current, $permission]) @@ -118,7 +118,7 @@ function storedReportResourceEntraReport(ManagedEnvironment $tenant, array $payl $report = storedReportResourcePermissionReport($tenant); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::test(ListStoredReports::class) ->assertCanSeeTableRecords([$report]); @@ -134,7 +134,7 @@ function storedReportResourceEntraReport(ManagedEnvironment $tenant, array $payl [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::test(ListStoredReports::class) ->assertSee('No stored reports yet') diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php index 3eef4530..0494a5fc 100644 --- a/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php @@ -21,7 +21,7 @@ function productKnowledgeSupportDiagnosticsTenantAuthorizationComponent(User $us { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php index d1c7cdf5..e0fcb746 100644 --- a/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php @@ -21,7 +21,7 @@ function productKnowledgeTenantSupportDiagnosticsComponent(User $user, ManagedEn { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php index e9c8c9e7..bd8604a0 100644 --- a/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php @@ -23,7 +23,7 @@ function tenantDiagnosticsTelemetryComponent(User $user, ManagedEnvironment $ten { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php index b1f71de8..6f465db8 100644 --- a/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php +++ b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php @@ -26,7 +26,7 @@ function supportDiagnosticsTenantAuditComponent(User $user, ManagedEnvironment $ { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php index 9dce6fa9..7da08582 100644 --- a/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php +++ b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php @@ -20,7 +20,7 @@ function supportDiagnosticsTenantAuthorizationComponent(User $user, ManagedEnvir { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php b/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php index 8a4eecff..7ebc36c5 100644 --- a/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php +++ b/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php @@ -30,7 +30,7 @@ function tenantSupportDiagnosticsComponent(User $user, ManagedEnvironment $tenan { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php index a9b5c858..06eb5266 100644 --- a/apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php @@ -26,7 +26,7 @@ function supportRequestAuditTenantComponent(User $user, ManagedEnvironment $tena { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php index d6daa287..473e9667 100644 --- a/apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php @@ -21,7 +21,7 @@ function supportRequestAuthorizationTenantComponent(User $user, ManagedEnvironme { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php index 3ee06971..5049c250 100644 --- a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php @@ -32,7 +32,7 @@ function spec256AuditTenantComponent(User $user, ManagedEnvironment $tenant): \L { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php index e076ed85..89dcf7df 100644 --- a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php @@ -24,7 +24,7 @@ function spec256AuthorizationTenantComponent(User $user, ManagedEnvironment $ten { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php index a44ca5aa..80d8d037 100644 --- a/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php +++ b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php @@ -22,7 +22,7 @@ function tenantSupportRequestComponent(User $user, ManagedEnvironment $tenant): { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php index 59495727..b436afa1 100644 --- a/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php +++ b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php @@ -31,7 +31,7 @@ function spec256TenantHandoffComponent(User $user, ManagedEnvironment $tenant): { test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); return Livewire::actingAs($user)->test(TenantDashboard::class); } diff --git a/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php b/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php index a077c806..fecf76a6 100644 --- a/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php +++ b/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php @@ -50,7 +50,7 @@ test()->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user)->test(TenantDashboard::class); diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php index 18061b82..4344781d 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php @@ -114,7 +114,7 @@ 'published_by_user_id' => (int) $user->getKey(), ])->save(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($user) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([ diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php index e15f3f7f..66c229a0 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php @@ -82,7 +82,7 @@ $zip->close(); unlink($tempFile); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(TenantReviewPackCard::class, ['record' => $tenant]) diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php index 53f6bfa2..d4b00544 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php @@ -43,7 +43,7 @@ expect($reasonSemantics)->not->toBeNull(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($user) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) @@ -79,7 +79,7 @@ expect($review->operation_run_id)->not->toBeNull(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($user) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([ diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php index 3b66af67..c66bb251 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php @@ -26,7 +26,7 @@ [$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly'); $review = composeTenantReviewForTest($tenant, $owner); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($readonly) ->test(ViewTenantReview::class, ['record' => $review->getKey()]) @@ -40,7 +40,7 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); $review = composeTenantReviewForTest($tenant, $user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $component = Livewire::actingAs($user) ->test(ViewTenantReview::class, ['record' => $review->getKey()]) diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php index 54850c7d..cad35781 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php @@ -36,7 +36,7 @@ ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) ->assertOk(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($readonly) ->test(ListTenantReviews::class) diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php index 60780f9d..27d905bf 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php @@ -50,7 +50,7 @@ function tenantReviewContractHeaderActions(Testable $component): array [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($user) ->test(ListTenantReviews::class) @@ -74,7 +74,7 @@ function tenantReviewContractHeaderActions(Testable $component): array $review = composeTenantReviewForTest($tenant, $user); $this->actingAs($user); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $livewire = Livewire::actingAs($user) ->test(ListTenantReviews::class) @@ -99,7 +99,7 @@ function tenantReviewContractHeaderActions(Testable $component): array $review = composeTenantReviewForTest($tenant, $owner); $refreshRule = GovernanceActionCatalog::rule('refresh_review'); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); Livewire::actingAs($readonly) ->test(ViewTenantReview::class, ['record' => $review->getKey()]) @@ -142,7 +142,7 @@ function tenantReviewContractHeaderActions(Testable $component): array [$owner, $tenant] = createUserWithTenant(role: 'owner'); $review = composeTenantReviewForTest($tenant, $owner); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($owner) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) @@ -195,7 +195,7 @@ function tenantReviewContractHeaderActions(Testable $component): array $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); - setTenantPanelContext($tenant); + setAdminEnvironmentContext($tenant); $this->actingAs($user) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([ diff --git a/apps/platform/tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php b/apps/platform/tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php new file mode 100644 index 00000000..6ba5f5ac --- /dev/null +++ b/apps/platform/tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php @@ -0,0 +1,65 @@ +get(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeNull(); +})->with([ + '/admin/t', + '/admin/t/example', + '/admin/tenants', + '/admin/tenants/example', + '/admin/tenants/example/required-permissions', + '/admin/tenants/example/provider-connections', +]); + +it('drops unsafe external intended URLs on consume', function (): void { + session()->put(WorkspaceContext::INTENDED_URL_SESSION_KEY, 'https://example.test/admin/workspaces/1/environments'); + + expect(WorkspaceIntendedUrl::consume())->toBeNull(); +}); + +it('rejects retired intended URLs and falls back to the canonical environment destination', function (string $intendedUrl): void { + [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); + $workspace = $tenant->workspace()->firstOrFail(); + + $resolved = app(WorkspaceRedirectResolver::class)->resolve($workspace, $user, $intendedUrl); + + expect($resolved)->toBe(ManagedEnvironmentLinks::viewUrl($tenant)) + ->and($resolved)->not->toContain('/admin/tenants') + ->and($resolved)->not->toContain('/admin/t/'); +})->with([ + '/admin/t/example', + '/admin/tenants', + '/admin/tenants/example', + '/admin/tenants/example/provider-connections', + 'https://example.test/admin/tenants/example', +]); + +it('normalizes legacy operations intended URL to the workspace-scoped operations route', function (): void { + [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); + $workspace = $tenant->workspace()->firstOrFail(); + + $resolved = app(WorkspaceRedirectResolver::class)->resolve($workspace, $user, '/admin/operations?activeTab=active'); + + expect($resolved)->toBe(ManagedEnvironmentLinks::operationsUrl($workspace, ['activeTab' => 'active'])) + ->and($resolved)->toContain('/admin/workspaces/') + ->and($resolved)->not->toContain('/admin/operations?'); +}); + +it('does not preserve ambiguous legacy operation detail intended URLs', function (): void { + [$user, $tenant] = createMinimalUserWithTenant(role: 'owner'); + $workspace = $tenant->workspace()->firstOrFail(); + + $resolved = app(WorkspaceRedirectResolver::class)->resolve($workspace, $user, '/admin/operations/123'); + + expect($resolved)->toBe(ManagedEnvironmentLinks::viewUrl($tenant)); +}); + diff --git a/apps/platform/tests/Pest.php b/apps/platform/tests/Pest.php index 9504e6a7..5d002d9c 100644 --- a/apps/platform/tests/Pest.php +++ b/apps/platform/tests/Pest.php @@ -1365,11 +1365,14 @@ function restateTenantReviewEvidenceSnapshot( return $snapshot->fresh('items'); } -function setTenantPanelContext(ManagedEnvironment $tenant): void +function setAdminEnvironmentContext(ManagedEnvironment $tenant): void { setAdminPanelContext($tenant); } +/** + * Set the workspace-first admin panel context for tests. There is no TenantPanel compatibility helper. + */ function setAdminPanelContext(?ManagedEnvironment $tenant = null): void { if ($tenant instanceof ManagedEnvironment) { diff --git a/apps/platform/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php b/apps/platform/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php index ea0331c6..8488addb 100644 --- a/apps/platform/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php +++ b/apps/platform/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php @@ -9,7 +9,7 @@ uses(RefreshDatabase::class); -test('ProviderConnectionResource::getUrl infers tenant from referer during Livewire requests', function (): void { +test('ProviderConnectionResource::getUrl infers tenant from canonical referer query during Livewire requests', function (): void { $tenant = ManagedEnvironment::factory()->create([ 'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf', ]); @@ -22,7 +22,7 @@ $request = Request::create($updateUri, 'POST'); $request->headers->set('x-livewire', '1'); - $request->headers->set('referer', "http://localhost/admin/tenants/{$tenant->external_id}/provider-connections/1/edit"); + $request->headers->set('referer', "http://localhost/admin/provider-connections/1/edit?managed_environment_id={$tenant->external_id}"); app()->instance('request', $request); expect(ManagedEnvironment::query()->where('slug', $tenant->external_id)->exists())->toBeTrue(); diff --git a/specs/297-managed-environment-canonical-route-cutover/checklists/requirements.md b/specs/297-managed-environment-canonical-route-cutover/checklists/requirements.md new file mode 100644 index 00000000..62cbdc67 --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/checklists/requirements.md @@ -0,0 +1,51 @@ +# Requirements Checklist: Managed Environment Canonical Route Cutover + +**Purpose**: Preparation readiness checklist for Spec 297. +**Created**: 2026-05-12 + +## Spec Readiness + +- [x] Problem statement names the active legacy surfaces. +- [x] Goals distinguish product/route/UI/test truth from DB/model rename. +- [x] Non-goals explicitly forbid compatibility layer, DB rename, broad localization, Package Execution, Guided Operations, Microsoft provider refactor, and broad RBAC refactor. +- [x] Functional requirements cover TenantPanelProvider, `/admin/t`, `/admin/tenants`, canonical managed-environment links, intended URLs, required permissions, provider connections, helper rename, copy cleanup, guards, and RBAC. +- [x] Acceptance criteria define route, link, helper, intended URL, provider/permission, RBAC, and validation outcomes. +- [x] Final implementation output contract is included. + +## Constitution / Governance + +- [x] SPEC-GATE-001 candidate check is filled. +- [x] Proportionality review is filled for the possible link helper and spec-local audit artifact. +- [x] No new persisted truth is introduced. +- [x] Workspace isolation and managed-environment entitlement are explicit. +- [x] RBAC 404/403 semantics are explicit. +- [x] Provider boundary handling distinguishes platform route truth from Microsoft tenant ID terminology. +- [x] Test governance is explicit and bounded. + +## Filament / Laravel + +- [x] Filament v5 / Livewire v4 compliance is explicitly stated. +- [x] Provider registration location is `apps/platform/bootstrap/providers.php`. +- [x] Global search impact for retired resources is called out. +- [x] Destructive-action confirmation and server authorization expectations are preserved. +- [x] Asset strategy is unchanged unless implementation discovers otherwise. +- [x] Testing plan names Filament/Page/Action/Guard proof surfaces. + +## Implementation Readiness + +- [x] `spec.md` exists. +- [x] `plan.md` exists. +- [x] `tasks.md` exists. +- [x] `research.md` exists. +- [x] `data-model.md` exists and says no persistence changes are planned. +- [x] `quickstart.md` exists. +- [x] `legacy-surface-audit.md` exists with initial prep findings and refresh requirements. +- [x] Logical route/link contract exists. +- [x] Tasks are ordered, small, and verifiable. + +## Open Items For Implementation + +- [ ] Refresh `legacy-surface-audit.md` after branch/session setup. +- [ ] Determine whether an existing managed-environment link helper can be extended. +- [ ] Determine the exact TenantResource retirement mechanism. +- [ ] Determine whether any old `/admin/tenants...` URL has safe canonical resolution; default remains 404. diff --git a/specs/297-managed-environment-canonical-route-cutover/contracts/managed-environment-canonical-route-contract.md b/specs/297-managed-environment-canonical-route-cutover/contracts/managed-environment-canonical-route-contract.md new file mode 100644 index 00000000..85602f6f --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/contracts/managed-environment-canonical-route-contract.md @@ -0,0 +1,87 @@ +# Contract: Managed Environment Canonical Route Cutover + +**Status**: Logical route/link contract +**Runtime persistence**: none +**Compatibility**: no broad compatibility surface + +## Canonical Route Families + +| Product case | Canonical route family | Notes | +|---|---|---| +| Environment index | `/admin/workspaces/{workspace}/environments` | Workspace context required | +| Environment detail | `/admin/workspaces/{workspace}/environments/{environment}` | Environment must belong to workspace | +| Required permissions / readiness | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | Existing repo-real route preferred | +| Diagnostics / provider health | `/admin/workspaces/{workspace}/environments/{environment}/diagnostics` or repo-real equivalent | If no route exists, implementation must document canonical equivalent | +| Access scopes / memberships | `/admin/workspaces/{workspace}/environments/{environment}/access-scopes` or repo-real equivalent | If no route exists, implementation must document canonical equivalent | +| Provider connections | `/admin/provider-connections...` | Tenantless admin resource with neutral scope context | +| Operations index | `/admin/workspaces/{workspace}/operations` | Workspace context required | +| Operation detail | `/admin/workspaces/{workspace}/operations/{run}` | Run entitlement required | + +## Retired Route Families + +| Route family | Contract | +|---|---| +| `/admin/t` | Absent or 404 | +| `/admin/t/*` | Absent or 404 | +| `/admin/tenants` | Not active product surface; 404 or documented safe canonical resolution only | +| `/admin/tenants/{environment}` | Not active product surface; 404 or documented safe canonical resolution only | +| `/admin/tenants/{environment}/edit` | 404 | +| `/admin/tenants/{environment}/memberships` | 404 or documented safe canonical access-scope resolution only | +| `/admin/tenants/{environment}/required-permissions` | 404 or documented safe canonical required-permissions resolution only | +| `/admin/tenants/{environment}/provider-connections...` | 404 | +| `/admin/operations` | Not final intended URL; normalize to workspace operations if workspace known | + +## Link Helper Contract + +If `ManagedEnvironmentLinks` is introduced or extended, it must provide or delegate these behaviors: + +```php +ManagedEnvironmentLinks::indexUrl($workspace) +ManagedEnvironmentLinks::viewUrl($environment) +ManagedEnvironmentLinks::requiredPermissionsUrl($environment) +ManagedEnvironmentLinks::diagnosticsUrl($environment) +ManagedEnvironmentLinks::accessScopesUrl($environment) +ManagedEnvironmentLinks::operationsUrl($workspace, ?ManagedEnvironment $environment = null) +``` + +The exact method names may differ if the repo already has a canonical helper. The behavior must remain equivalent. + +## Authorization Contract + +- Link generation does not grant authorization. +- Page/action owners still enforce workspace membership and managed-environment entitlement. +- Non-member/out-of-scope access returns 404. +- Established member missing capability returns 403. +- Managed-environment scope cannot grant role/capability authority. + +## Intended URL Contract + +Rejected as final destination: + +```text +/admin/t +/admin/t/* +/admin/tenants +/admin/tenants/* +/admin/tenants/*/required-permissions +/admin/tenants/*/provider-connections +external URLs +``` + +Normalized when safe: + +```text +/admin/operations -> /admin/workspaces/{workspace}/operations +``` + +Fallback when unsafe: + +```text +/admin/workspaces/{workspace}/overview +``` + +or: + +```text +/admin/workspaces/{workspace}/environments +``` diff --git a/specs/297-managed-environment-canonical-route-cutover/data-model.md b/specs/297-managed-environment-canonical-route-cutover/data-model.md new file mode 100644 index 00000000..83c5afcb --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/data-model.md @@ -0,0 +1,61 @@ +# Data Model: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement + +**Date**: 2026-05-12 +**Runtime persistence changes**: none planned. + +## Existing Domain Truth + +| Concept | Current role in this spec | Persistence change | +|---|---|---| +| Workspace | Primary SaaS/admin context and route scope | None | +| ManagedEnvironment | Secondary managed target context under a workspace | None | +| Tenant / internal tenant model | Existing technical implementation detail where repo-real | None | +| WorkspaceMembership | Role/capability authority | None | +| ManagedEnvironmentMembership | Access-scope / narrowing-only overlay | None | +| ProviderConnection | Tenantless admin resource with neutral scope context | None | +| OperationRun | Existing execution truth linked through workspace operations routes | None | + +## Route Truth + +| Old route family | New truth | Data implication | +|---|---|---| +| `/admin/t...` | Retired / 404 | None | +| `/admin/tenants...` | Retired as active product surface | None | +| `/admin/tenants/{environment}/required-permissions` | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` or repo-real equivalent | None | +| `/admin/tenants/{environment}/provider-connections...` | `/admin/provider-connections...` with neutral scope context or canonical environment context | None | +| `/admin/operations` | `/admin/workspaces/{workspace}/operations` when workspace known | None | + +## New Structures + +No new persisted tables, columns, enum/status families, lifecycle states, or source-of-truth records are introduced. + +One bounded runtime helper may be introduced if needed: + +```text +App\Support\ManagedEnvironmentLinks +``` + +Allowed helper responsibility: + +- Generate canonical URLs for existing workspace/environment routes. +- Require enough workspace/environment context to avoid ambiguous routing. +- Delegate operations URLs to existing OperationRun link helpers. + +Forbidden helper responsibility: + +- Acting as a generic route registry. +- Creating compatibility redirects. +- Inferring authorization from route generation. +- Introducing a new persistent route mapping. + +## RBAC Semantics + +- Workspace membership carries role/capability authority. +- Managed-environment membership narrows access only. +- Non-member or out-of-scope workspace/environment access returns 404. +- Established member missing capability returns 403. +- Legacy `role` data on managed-environment membership, if present, is not authority. + +## Compatibility + +No compatibility data model exists for this cutover. Historical rows, old route aliases, and old helper aliases are not preserved. diff --git a/specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md b/specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md new file mode 100644 index 00000000..cd399d57 --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md @@ -0,0 +1,92 @@ +# Legacy Surface Audit: Spec 297 + +**Prepared**: 2026-05-12 +**Status**: Implementation and verification complete on branch `297-managed-environment-canonical-route-cutover`. + +## Commands Run During Prep + +| Command | Result | +|---|---| +| `git status --short --branch` | Clean on `platform-dev` before Spec Kit branch creation | +| `.specify/scripts/bash/create-new-feature.sh --json --number 297 --short-name managed-environment-canonical-route-cutover ...` | Created branch and spec directory `297-managed-environment-canonical-route-cutover` | +| `.specify/scripts/bash/setup-plan.sh --json` | Created `plan.md` from template | +| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "admin/t\|admin/tenants\|provider-connections\|required-permissions\|workspaces/.*/environments\|operations"` | Found active `/admin/tenants` routes plus canonical workspace/environment and workspace operations routes | +| `cd apps/platform && ./vendor/bin/sail artisan route:list --path=admin/tenants` | Shows 4 TenantResource routes | +| `cd apps/platform && ./vendor/bin/sail artisan route:list --path=admin/workspaces` | Shows canonical `/admin/workspaces/{workspace}/environments...` and `/admin/workspaces/{workspace}/operations...` route families | +| `cd apps/platform && ./vendor/bin/sail artisan route:list --path=admin/provider-connections` | Shows tenantless provider-connection resource routes | +| `cd apps/platform && ./vendor/bin/sail artisan route:list --columns=...` | Unsupported option in current Artisan route:list; retried without `--columns` | +| `cd apps/platform && rg "TenantPanelProvider|panel:\\s*'tenant'|panel:\\s*\\\"tenant\\\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\\.operations" . --glob '!vendor' --glob '!node_modules'` | Found active runtime and test references requiring implementation classification | +| `git status --short --branch` | `## 297-managed-environment-canonical-route-cutover`; only untracked spec package present before runtime edits | +| `git diff --stat` | Empty before runtime edits | +| `git log -1 --oneline` | `928d49b5 Merge remote-tracking branch 'origin/platform-dev' into platform-dev` | +| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "admin/t\|admin/tenants\|provider-connections\|required-permissions\|workspaces/.*/environments\|operations"` | Confirmed active `/admin/tenants` TenantResource routes, canonical workspace/environment routes, canonical workspace operations routes, tenantless provider-connection routes, and canonical required-permissions route | +| `cd apps/platform && rg "TenantPanelProvider|panel:\\s*'tenant'|panel:\\s*\\\"tenant\\\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\\.operations" . --glob '!vendor' --glob '!node_modules'` | Confirmed retired panel provider file, `TenantResource::getUrl(...)` runtime links, canonical `TenantDashboard::getUrl(...)` references, provider-connection legacy URL parser compatibility, legacy intended-URL acceptance, old Pest helper name, and historical/guard references | + +## Findings + +| Finding | Runtime/Test/Copy | Active? | Decision | Fixed? | +|---|---|---:|---|---:| +| `apps/platform/app/Providers/Filament/TenantPanelProvider.php` exists | Runtime | No | Deleted; `bootstrap/providers.php` remains Admin/System only | Yes | +| `/admin/tenants` route family exists via TenantResource | Runtime | No | Retired as active product surface; route scan now returns no matches | Yes | +| `/admin/workspaces/{workspace}/environments...` route family exists | Runtime | Yes | Reuse as canonical managed-environment route family | N/A | +| `/admin/workspaces/{workspace}/operations...` route family exists | Runtime | Yes | Reuse as canonical operations route family | N/A | +| `/admin/provider-connections...` tenantless route family exists | Runtime | Yes | Keep as canonical provider-connection route family | N/A | +| `setTenantPanelContext()` exists in `tests/Pest.php` | Test | No | Renamed to `setAdminEnvironmentContext()` with no alias; remaining hits are guard regex literals only | Yes | +| Runtime `TenantResource::getUrl(...)` references remain | Runtime | No | Replaced with `ManagedEnvironmentLinks` or canonical resource query URLs; app/resource scan is clean | Yes | +| Runtime `TenantDashboard::getUrl(...)` references remain | Runtime | No | Replaced in app/runtime surfaces; dashboard page `getUrl()` itself delegates to canonical helper | Yes | +| Runtime `TenantRequiredPermissions::getUrl(...)` references remain | Runtime/Test | No | Replaced with canonical required-permissions route/helper | Yes | +| Tests still assert `/admin/t...` or `/admin/tenants...` in multiple historical and current files | Test | Yes | Rebaselined to retired-route assertions, canonical routes, or historical-only allowed references | Yes | +| Old product copy may remain in touched active surfaces | Copy | Bounded | Touched active surfaces neutralized; remaining hits are outside touched files, guard fixtures, provider/technical names, or follow-up copy scope | Yes | +| `AdminPanelProvider` explicitly registers `TenantResource::class` | Runtime | No | Removed explicit registration; `TenantResource` stays technical/dormant with canonical URL override and global search disabled | Yes | +| `TenantResource` is still used as a table/form/action owner by canonical workspace pages | Runtime | Yes | Kept as technical owner only; URL generation is canonical and resource routes are inactive | Yes | +| `ProviderConnectionResource::extractTenantExternalIdFromUrl()` accepts old `/admin/tenants` and `/admin/t` referers | Runtime compatibility | No | Removed legacy referer parsing; canonical `managed_environment_id` query/context remains | Yes | +| `WorkspaceIntendedUrl` accepts `/admin/t...` and `/admin/tenants...` as safe admin intended URLs | Runtime | No | Rejects retired tenant routes at store/consume time | Yes | +| `WorkspaceRedirectResolver` preserves matching `/admin/t...` and `/admin/tenants...` intended URLs | Runtime | No | Rejects retired tenant routes and normalizes exact legacy `/admin/operations` to workspace-scoped operations | Yes | +| `TenantRequiredPermissions::reRunVerificationUrl()` uses `TenantResource::getUrl('view')` | Runtime | No | Uses canonical managed-environment detail helper | Yes | +| `ManageTenantMemberships` back action uses `TenantResource::getUrl('view')` | Runtime | No | Uses canonical managed-environment detail helper | Yes | +| `ProviderConnectionResource` environment backlink uses `TenantResource::getUrl('view')` | Runtime | No | Uses canonical managed-environment detail helper | Yes | + +## Allowed Reference Classes + +| Reference | Why allowed | Follow-up | +|---|---|---| +| Historical specs and audit docs | Repository history, not active runtime truth | None | +| Internal model/table/class names such as `Tenant` | DB/model rename is out of scope | Separate DB/model rename spec only if product requires it | +| Microsoft Entra tenant ID / provider-specific tenant copy | External provider terminology | None | +| Guard tests that assert old paths are 404 | Regression protection | Keep focused and explicit | +| `TenantResource` class name and internal helper references | Technical resource/model naming remains out of scope | Keep only if URL output is canonical and resource is not active as `/admin/tenants` | +| Copy scan hits in `lang/en/localization.php`, `FindingExceptionsQueue`, baseline compare, governance-action internals, and assignment relation internals | Outside touched active cutover surfaces, guard fixtures, or technical/historical tenant-domain wording | Future localization/spec only if product asks for broad copy neutralization | + +## Implementation Verification Commands + +| Command | Result | +|---|---| +| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "admin/tenants\|admin/t/"` | No matches; retired routes are inactive | +| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "workspaces/.*/environments\|provider-connections\|required-permissions\|operations"` | Shows canonical workspace/environment, provider-connection, required-permissions, and workspace operations route families | +| `cd apps/platform && rg "filament\\.admin\\.resources\\.tenants\|/admin/tenants\|/admin/t/\|TenantResource::getUrl\|TenantDashboard::getUrl\|TenantRequiredPermissions::getUrl\|setTenantPanelContext\|panel:\\s*'tenant'\|panel:\\s*\\\"tenant\\\"" app resources routes --glob '!vendor' --glob '!node_modules'` | No matches | +| `cd apps/platform && rg "setTenantPanelContext\|panel:\\s*'tenant'\|panel:\\s*\\\"tenant\\\"" tests --glob '!vendor' --glob '!node_modules'` | Only `Spec288NoLegacyRouteAndHelperGuardTest` regex guard literals remain | +| `cd apps/platform && rg "Tenant dashboard\|Tenant detail\|Open tenant\|Select tenant\|Tenant scope\|Remove tenant\|Restore tenant\|Tenant memberships" app resources lang tests --glob '!vendor' --glob '!node_modules'` | Remaining hits classified as allowed/out-of-scope above | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php tests/Feature/ProviderConnections/LegacyRedirectTest.php tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php` | 51 passed, 173 assertions | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards` | 265 passed, 4653 assertions | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces` | 96 passed, 276 assertions | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections` | 78 passed, 588 assertions | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions` | 21 passed, 82 assertions | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament` | 765 passed, 5 skipped, 4975 assertions | +| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php` | 2 passed, 29 assertions | +| `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` | Pass | +| `git diff --check` | Pass | + +## Implementation Refresh Requirements + +Before runtime edits, refresh this audit with: + +```bash +git status --short --branch +git diff --stat + +cd apps/platform +./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations" +rg "TenantPanelProvider|panel:\s*'tenant'|panel:\s*\"tenant\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\.operations" . --glob '!vendor' --glob '!node_modules' +``` + +Update the `Fixed?` column as implementation progresses. diff --git a/specs/297-managed-environment-canonical-route-cutover/plan.md b/specs/297-managed-environment-canonical-route-cutover/plan.md new file mode 100644 index 00000000..45c92dfd --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/plan.md @@ -0,0 +1,314 @@ +# Implementation Plan: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement + +**Branch**: `297-managed-environment-canonical-route-cutover` | **Date**: 2026-05-12 | **Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/spec.md) +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/spec.md` + +## Summary + +Spec 297 completes the hard product cutover from legacy tenant surfaces to canonical workspace-managed-environment routes. The implementation retires active `/admin/tenants...` product routes, keeps `/admin/t...` dead, removes or permanently neutralizes `TenantPanelProvider`, replaces runtime link generation with one canonical managed-environment link contract, rejects legacy intended URLs, renames the old tenant-panel test helper with no alias, and adds guard tests that prevent backsliding. + +This plan is preparation only. It does not implement application code. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12.52.0, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, Laravel Sail 1.52.0 +**Storage**: PostgreSQL through Laravel/Sail for tests; no new storage planned +**Testing**: Pest via `./vendor/bin/sail artisan test --compact`; Browser tests only if visible navigation is touched +**Validation Lanes**: targeted Feature guards, Workspaces, ProviderConnections, RequiredPermissions, Filament, Spec 288 guard pack, Spec 293 cutover lane, optional Browser smoke +**Target Platform**: Laravel Sail local runtime and Gitea-compatible CI runners +**Project Type**: Laravel web application under `apps/platform` +**Performance Goals**: Route/link guards stay deterministic and focused; no new heavy or browser defaults +**Constraints**: no `/admin/t...` restoration, no `/admin/tenants...` compatibility surface, no TenantPanelProvider reactivation, no old helper alias, no DB/model rename, no broad localization or RBAC refactor +**Scale/Scope**: Route, link, intended URL, Filament resource registration, and test-helper cutover only + +## Initial Repo Baseline + +Preparation audit on 2026-05-12 found: + +- Current branch before Spec Kit execution: `platform-dev`; Spec Kit switched to `297-managed-environment-canonical-route-cutover`. +- Working tree was clean before creating the spec package. +- `TenantPanelProvider` still exists at `apps/platform/app/Providers/Filament/TenantPanelProvider.php`. +- `apps/platform/bootstrap/providers.php` is already guarded by existing tests against registering `TenantPanelProvider`. +- `route:list --path=admin/tenants` currently shows four active Filament tenant resource routes: index, view, edit, memberships. +- `route:list --path=admin/workspaces` currently shows canonical environment routes under `/admin/workspaces/{workspace}/environments...` and workspace operations under `/admin/workspaces/{workspace}/operations...`. +- `rg` currently finds many active tests/runtime references to `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, `TenantRequiredPermissions::getUrl(...)`, `/admin/t/...`, `/admin/tenants...`, and `setTenantPanelContext()`. +- The attempted `route:list --columns=...` option is unsupported in this Laravel version; retry without `--columns`. + +The implementation must refresh `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md` before editing runtime code. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed route/link/navigation contract for existing surfaces; no new product workflow. +- **Native vs custom classification summary**: Native Filament/resources/pages and shared link helpers. No custom Blade/Tailwind surface is planned. +- **Shared-family relevance**: navigation entry points, action links, notifications/toast actions, OperationRun links, provider/permission links, test context helpers. +- **State layers in scope**: route registration, URL helper, intended URL/session, Filament panel/resource registration, test panel/workspace/environment context. +- **Audience modes in scope**: operator-MSP and support-platform only through existing surfaces. +- **Decision/diagnostic/raw hierarchy plan**: existing environment/readiness/operations surfaces keep their hierarchy; the cutover only changes canonical route truth. +- **Raw/support gating plan**: unchanged; raw provider detail remains where existing policies allow it. +- **One-primary-action / duplicate-truth control**: do not add parallel actions to preserve legacy paths. Replace old destinations with canonical ones. +- **Handling modes by drift class or surface**: retire, replace, or document allowed technical reference. Unsafe or ambiguous legacy URL resolution falls back or 404s. +- **Repository-signal treatment**: review-mandatory for route-list output, source-scan allowlists, intended URL fallback, helper rename, and any remaining `Tenant` product copy in touched files. +- **Special surface test profiles**: `route-contract`, `standard-native-filament`, `global-context-shell`, `browser-smoke` if visible navigation changes. +- **Required tests or manual smoke**: targeted Pest guard tests first; Browser only when implementation touches visible navigation flows. +- **Exception path and spread control**: Allowed remaining technical `Tenant` references must be listed in `legacy-surface-audit.md` or final summary. +- **Active feature PR close-out entry**: Guardrail / Route Cutover / Smoke Coverage if browser proof was run. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes. +- **Systems touched**: Filament panel providers, TenantResource/TenantDashboard/TenantRequiredPermissions routes or links, WorkspaceRedirectResolver/intended URL support, OperationRunLinks, WorkspaceOverviewBuilder, provider/required-permissions link emitters, `tests/Pest.php`, guard tests, browser tests when route navigation is visible. +- **Shared abstractions reused**: existing workspace/environment routes, `WorkspaceContext`, `OperationRunLinks`, existing admin panel context helper, existing Spec 288/293 guard style. +- **New abstraction introduced? why?**: Only a bounded `ManagedEnvironmentLinks` helper if no existing repo-real helper owns canonical environment URLs. It exists to remove scattered route-name literals and prevent legacy URL generation. +- **Why the existing abstraction was sufficient or insufficient**: The canonical routes exist, but runtime link generation remains scattered and some helpers still emit old destinations. +- **Bounded deviation / spread control**: Technical `Tenant` model names and Microsoft tenant ID copy remain only where non-product or provider-specific. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, route/link safety only. +- **Central contract reused**: `OperationRunLinks` and `admin.operations.index` / `admin.operations.view` with explicit workspace context. +- **Delegated UX behaviors**: preserve existing `View operation` / `Open operation` behavior. +- **Surface-owned behavior kept local**: environment/provider surfaces own only initiation inputs and page-local copy. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: unchanged. +- **Exception path**: none. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Microsoft Entra tenant ID copy, Graph permission names, provider diagnostics payloads. +- **Platform-core seams**: route family, link generation, workspace/environment context, operations links, RBAC and access-scope semantics. +- **Neutral platform terms / contracts preserved**: workspace, managed environment, provider connection, target scope, required permissions, diagnostics, access scope, operation. +- **Retained provider-specific semantics and why**: Microsoft-specific identity/permission terms remain only when they identify external provider truth. +- **Bounded extraction or follow-up path**: No multi-provider framework. Follow-up only for DB/model rename or broader provider-boundary cleanup beyond route cutover. + +## Constitution Check + +*GATE: Must pass before runtime implementation and re-check before close-out.* + +- Inventory-first: no new inventory or snapshot truth. +- Read/write separation: no new write workflow. Existing destructive actions touched by route/resource work keep confirmation, authorization, and audit behavior. +- Single Graph contract path: no new Graph calls. +- Deterministic capabilities: capability-first RBAC remains authoritative; no role-string checks. +- Proportionality / no premature abstraction: use existing helper if possible; any new link helper is bounded to current route generation. +- No new persisted truth: no migrations, tables, compatibility shims, or dual-read paths. +- Workspace isolation: all environment and operations links carry explicit workspace context or validate current workspace context. +- Tenant isolation: tenant-owned records exposed through canonical environment routes still enforce managed-environment entitlement. +- RBAC-UX: non-member/out-of-scope remains 404; established member missing capability remains 403; UI hiding is not security. +- Provider boundary: tenant-first platform route language is retired; provider-specific tenant terms remain only provider-owned. +- Test governance: guard tests are allowed and focused; no full-suite repair or new lane framework. +- Filament-native UI: Filament remains v5 on Livewire v4, no v3/v4 API usage, no ad-hoc UI redesign. +- Deployment/ops: no asset registration is planned. If assets are unexpectedly registered, deploy notes include `cd apps/platform && php artisan filament:assets`. + +## Filament v5 Output Contract + +- **Livewire compliance**: Filament v5 targets Livewire v4.0+; current app has Livewire 4.1.4. +- **Provider registration location**: Laravel 12 provider registration must remain in `apps/platform/bootstrap/providers.php`. `TenantPanelProvider` must not be registered there. +- **Globally searchable resources**: If `TenantResource` is retired or moved out of active discovery, global search must be disabled for it or it must no longer register. Any managed-environment resource that remains globally searchable must have Edit or View pages. +- **Destructive actions**: This spec does not add destructive actions. Any touched existing destructive action must still execute through `->action(...)`, use `->requiresConfirmation()`, and enforce server-side authorization. +- **Asset strategy**: No new Filament assets are planned. If implementation unexpectedly registers assets, deployment must include `cd apps/platform && php artisan filament:assets`. +- **Testing plan**: Pages/actions/helpers changed by the cutover are covered with Pest/Filament tests; guard tests cover route resurrection, helper resurrection, intended URL rejection, legacy URL generation, and managed-environment canonical links. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature guard tests for route/link/intended URL contracts; Unit tests for pure helper logic; Feature/Filament tests for pages/resources; Browser only for visible navigation smoke. +- **Affected validation lanes**: Feature/Guards, Feature/Workspaces, Feature/ProviderConnections, Feature/RequiredPermissions, Feature/Filament, Spec 288 guard pack, Spec 293 cutover lane, optional Browser lane. +- **Why this lane mix is the narrowest sufficient proof**: The risk is route/link resurrection, not complete product behavior. Focused guards plus existing domain test directories prove the changed contracts. +- **Narrowest proving commands**: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php +./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php +./vendor/bin/sail artisan test --compact tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php +./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php +``` + +- **Fixture / helper / factory / seed / context cost risks**: The replacement for `setTenantPanelContext()` must not make provider setup, browser fixtures, or broad workspace setup implicit. +- **Expensive defaults or shared helper growth introduced?**: none planned. +- **Heavy-family additions, promotions, or visibility changes**: none planned. +- **Surface-class relief / special coverage rule**: Standard-native Filament coverage unless route/navigation changes are visible in browser flows. +- **Closing validation and reviewer handoff**: run focused guards, affected domain directories, Spec 288 pack, Spec 293 pack, and Pint dirty. +- **Budget / baseline / trend follow-up**: document any material guard runtime increase in the implementation close-out. +- **Review-stop questions**: Does `/admin/tenants...` still return a product page? Does a helper still emit legacy URLs? Does intended URL handling preserve legacy paths? Did a test helper alias keep the old name? Did RBAC weaken? +- **Escalation path**: document-in-feature for allowed technical references; follow-up-spec for structural rename/localization issues. +- **Active feature PR close-out entry**: Guardrail / Route Cutover. +- **Why no dedicated follow-up spec is needed**: The route cutover is bounded. DB/model rename and broader copy/localization are explicit non-goals and can become follow-ups only if product needs them. + +## Project Structure + +### Documentation (this feature) + +```text +specs/297-managed-environment-canonical-route-cutover/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── legacy-surface-audit.md +├── tasks.md +├── contracts/ +│ └── managed-environment-canonical-route-contract.md +└── checklists/ + └── requirements.md +``` + +### Source Code (repository root) + +Expected touched surfaces during implementation: + +```text +apps/platform/app/ +├── Providers/Filament/ +├── Filament/ +│ ├── Pages/ +│ └── Resources/ +├── Support/ +│ ├── Workspaces/ +│ ├── OperationRunLinks.php +│ └── ManagedEnvironmentLinks.php (only if needed) +└── Http/ + +apps/platform/bootstrap/providers.php +apps/platform/routes/web.php +apps/platform/tests/ +├── Pest.php +├── Feature/ +├── Unit/ +└── Browser/ +``` + +**Structure Decision**: Use existing Laravel/Filament app structure and existing route/helper/test conventions. Do not create a new base application folder or dependency. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| Cross-cutting route/link/test-helper cleanup | Legacy route truth exists in multiple owners and cannot be retired safely in one local page | Local cleanup would leave intended URLs, tests, or link builders able to resurrect old paths | +| Bounded canonical link helper if needed | Runtime link generation must have one owner to make guard tests meaningful | Scattered route-name literals would recreate the drift this spec removes | +| New guard tests | Regression risk is route/link resurrection after a cutover | Manual review and ad hoc source scans are not durable enough | + +## Phase 0: Safety Gate + +1. Run: + +```bash +git status --short --branch +git diff --stat +git log -1 --oneline +``` + +2. Confirm the implementation branch is `297-managed-environment-canonical-route-cutover` or a session branch created from it. +3. Stop if unrelated uncommitted changes exist. +4. Read: + +```text +.specify/memory/constitution.md +specs/287-cutover-prerequisite-completion/ +specs/288-quality-gates-no-legacy-enforcement/ +specs/293-post-cutover-suite-stabilization/ +specs/296-full-suite-green-signal-restoration/ +``` + +## Phase 1: Baseline Audit + +Refresh `legacy-surface-audit.md` before code edits: + +```bash +git status --short --branch +git diff --stat + +cd apps/platform +./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations" +rg "TenantPanelProvider|panel:\s*'tenant'|panel:\s*\"tenant\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\.operations" . --glob '!vendor' --glob '!node_modules' +``` + +Classify each finding as runtime, test, copy, historical, provider-specific, or allowed technical reference. + +## Phase 2: Remove Dormant TenantPanelProvider + +- Delete `apps/platform/app/Providers/Filament/TenantPanelProvider.php` if no true runtime dependency exists. +- Ensure `apps/platform/bootstrap/providers.php` does not reference it. +- Replace tests that inspect the file with provider-registration and route-list guards. +- Add/extend `NoLegacyTenantPanelRuntimeTest`. + +## Phase 3: Establish Canonical Managed Environment Link Contract + +- Locate repo-real managed-environment route helpers first. +- Create or extend `ManagedEnvironmentLinks` only if needed. +- Cover index/detail/required-permissions/diagnostics/access-scopes/operations. +- Replace direct legacy link generation in runtime surfaces. +- Add contract tests that assert no generated URL contains `/admin/tenants` or `/admin/t/`. + +## Phase 4: Retire `/admin/tenants...` + +- Remove active TenantResource route registration or move it out of active discovery. +- If a temporary redirect is unavoidable, require unique workspace/environment resolution and document the exception. Default is 404. +- Update global search for any retired resource. +- Add/extend `NoActiveTenantResourceRoutesTest`. + +## Phase 5: Intended URL Legacy Rejection + +- Update `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, or repo-real intended URL owners. +- Reject `/admin/t...` and `/admin/tenants...` as final destinations. +- Normalize legacy `/admin/operations` to workspace operations when workspace is known. +- Fall back to workspace home or environment index when unsafe. +- Keep external URLs blocked. + +## Phase 6: Required Permissions And Provider Connections + +- Replace old required-permissions and provider-connection tenant URLs. +- Ensure tenantless provider-connection resource remains canonical. +- Ensure required-permissions uses the workspace/environment route. +- Add/extend legacy route tests proving old URLs do not return 200. + +## Phase 7: Test Helper Rename + +- Rename `setTenantPanelContext()` to the chosen canonical helper, for example `setAdminEnvironmentContext()`. +- Update every test usage. +- Do not keep an alias under the old name. +- Add guard coverage that fails on old helper resurrection. + +## Phase 8: Copy Cleanup In Touched Active Surfaces + +- Replace tenant-first product copy only in files touched by this cutover. +- Keep Microsoft/provider-specific tenant ID copy where correct. +- List remaining old references in `legacy-surface-audit.md`. + +## Phase 9: Regression Proof Pack + +Run focused proof: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php \ + tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php \ + tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php \ + tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php \ + tests/Feature/ProviderConnections/LegacyRedirectTest.php \ + tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php \ + tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php \ + tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php \ + tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php +``` + +Run the Spec 288 guard pack and Spec 293 cutover lane listed in the spec. Run browser smoke only if visible navigation flows were touched. + +## Phase 10: Broad Validation + +Run at least: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact tests/Feature/Guards +./vendor/bin/sail artisan test --compact tests/Feature/Workspaces +./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections +./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions +./vendor/bin/sail artisan test --compact tests/Feature/Filament +./vendor/bin/sail bin pint --dirty --format agent +git diff --check +``` + +Raw full suite is optional unless requested; if run, record the exact result. diff --git a/specs/297-managed-environment-canonical-route-cutover/quickstart.md b/specs/297-managed-environment-canonical-route-cutover/quickstart.md new file mode 100644 index 00000000..23de1a9f --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/quickstart.md @@ -0,0 +1,95 @@ +# Quickstart: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement + +Use this as the implementation command checklist. Refresh the baseline before editing code. + +## 1. Safety + +```bash +git status --short --branch +git diff --stat +git log -1 --oneline +``` + +## 2. Baseline Audit + +```bash +cd apps/platform +./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations" +rg "TenantPanelProvider|panel:\s*'tenant'|panel:\s*\"tenant\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\.operations" . --glob '!vendor' --glob '!node_modules' +``` + +Update: + +```text +specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md +``` + +## 3. Focused Proof + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php \ + tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php \ + tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php \ + tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php \ + tests/Feature/ProviderConnections/LegacyRedirectTest.php \ + tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php \ + tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php \ + tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php \ + tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php +``` + +## 4. Existing Guard Pack + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Feature/Guards/Spec288NoLegacyRouteAndHelperGuardTest.php \ + tests/Feature/Guards/Spec288ProviderCoreAndRoleAuthorityGuardTest.php \ + tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php \ + tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php \ + tests/Feature/Guards/BrowserLaneIsolationTest.php \ + tests/Feature/Guards/CiLaneFailureClassificationContractTest.php \ + tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php +``` + +## 5. Broad Focused Validation + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact tests/Feature/Guards +./vendor/bin/sail artisan test --compact tests/Feature/Workspaces +./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections +./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions +./vendor/bin/sail artisan test --compact tests/Feature/Filament +./vendor/bin/sail bin pint --dirty --format agent +git diff --check +``` + +## 6. Browser Smoke + +Run only if visible navigation/browser flows are touched: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php \ + tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php +``` + +## 7. Final Decision + +Close with exactly one: + +```text +Managed Environment canonical cutover complete; legacy tenant surfaces retired. +``` + +```text +Blocked by true runtime dependency on legacy tenant surface. +``` + +```text +Incomplete; active legacy tenant routes remain. +``` diff --git a/specs/297-managed-environment-canonical-route-cutover/research.md b/specs/297-managed-environment-canonical-route-cutover/research.md new file mode 100644 index 00000000..63a6ff22 --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/research.md @@ -0,0 +1,78 @@ +# Research: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement + +**Date**: 2026-05-12 +**Scope**: Preparation research only. No application implementation performed. + +## Decisions + +### Decision 1: Default legacy behavior is 404, not compatibility redirect + +**Decision**: `/admin/t...` and unsafe `/admin/tenants...` requests should be absent or return 404. A redirect is allowed only when the implementation can prove a unique, authorized workspace + managed-environment canonical URL. + +**Rationale**: The repo is pre-production under LEAN-001. Compatibility shims would preserve old product truth and contradict the explicit user instruction. + +**Alternatives rejected**: + +- Keep `/admin/tenants...` as hidden compatibility routes: rejected because hidden-but-routable surfaces still become test/runtime truth. +- Redirect all old tenant URLs broadly: rejected because workspace/environment resolution can be ambiguous and unsafe. + +### Decision 2: Canonical link generation gets one owner + +**Decision**: Use an existing repo-real managed-environment route helper if present. If none exists, add a bounded `ManagedEnvironmentLinks` helper that maps only current workspace/environment objects to current named routes. + +**Rationale**: Route names are currently spread through Filament resources/pages/tests. Centralizing the canonical contract makes guard tests meaningful without creating a routing framework. + +**Alternatives rejected**: + +- Scatter `route(...)` calls in each runtime surface: rejected because it recreates drift. +- Add a generic route registry/framework: rejected as disproportionate for current release truth. + +### Decision 3: TenantPanelProvider should be deleted if no true dependency exists + +**Decision**: Delete `TenantPanelProvider.php` when implementation confirms no runtime registration/dependency. If deletion is blocked, document the true dependency and guard against any route/provider activation. + +**Rationale**: Dormant runtime-ready panel code is a resurrection risk after the cutover. + +**Alternatives rejected**: + +- Leave the file as dormant code: rejected because it keeps tenant panel reactivation cheap and ambiguous. +- Keep tests that require the file to exist: rejected because tests should protect route/provider behavior, not dead code. + +### Decision 4: Test helper vocabulary must change without alias + +**Decision**: Replace `setTenantPanelContext()` with a canonical admin/workspace/environment helper such as `setAdminEnvironmentContext()` or `setManagedEnvironmentContext()` and keep no alias. + +**Rationale**: The helper name is product semantics. Keeping an alias preserves the retired panel as current test truth. + +**Alternatives rejected**: + +- Keep alias for migration ease: rejected under LEAN-001 and explicit user instruction. +- Rename only new tests: rejected because old tests would still encode the wrong product model. + +### Decision 5: Technical `Tenant` references may remain only as implementation detail + +**Decision**: Internal model/table/class names may remain where DB/model rename is out of scope. Product-facing routes, copy, and link truth must move to workspace/managed-environment terminology. + +**Rationale**: The spec explicitly forbids a DB rename migration and targets product, routing, UI, test, and link truth. + +**Alternatives rejected**: + +- Rename all Tenant classes/tables now: rejected as out of scope and high blast radius. +- Leave user-facing tenant-first copy in touched files: rejected because it contradicts the cutover. + +## Repo Findings From Preparation Audit + +| Finding | Evidence | Decision | +|---|---|---| +| Active `/admin/tenants` routes remain | `route:list --path=admin/tenants` shows TenantResource index/view/edit/memberships | Retire as active product surface | +| Canonical environment routes exist | `route:list --path=admin/workspaces` shows `/admin/workspaces/{workspace}/environments...` and workspace operations | Reuse as canonical target | +| TenantPanelProvider exists | `apps/platform/app/Providers/Filament/TenantPanelProvider.php` | Delete if no true dependency | +| Old helper remains | `apps/platform/tests/Pest.php` defines `setTenantPanelContext()` | Rename with no alias | +| Runtime/test legacy link calls remain | `rg` finds TenantResource/TenantDashboard/TenantRequiredPermissions URL generation | Replace or document allowed technical references | + +## Open Research Items For Implementation + +- Exact repo-real helper owner, if any, for managed-environment links. +- Exact route names for environment index/detail and access-scope route after TenantResource retirement. +- Whether TenantResource can be removed from discovery directly or must be moved/neutralized to preserve internal tests during the cutover. +- Whether any old `/admin/tenants...` request can be safely resolved to a canonical workspace/environment URL. Default remains 404. diff --git a/specs/297-managed-environment-canonical-route-cutover/spec.md b/specs/297-managed-environment-canonical-route-cutover/spec.md new file mode 100644 index 00000000..2e7b8b47 --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/spec.md @@ -0,0 +1,378 @@ +# Feature Specification: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement + +**Feature Branch**: `297-managed-environment-canonical-route-cutover` +**Created**: 2026-05-12 +**Status**: Ready +**Input**: User-provided Spec 297 prompt: retire active `/admin/tenants...` and remaining legacy tenant surfaces, remove dormant TenantPanel runtime readiness, and make Workspace -> Managed Environments the only active product route truth. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: The workspace-first managed-environment cutover is not complete while active `/admin/tenants...` product routes, `TenantPanelProvider`, tenant-panel test helpers, legacy intended URLs, and old TenantResource/TenantDashboard link generation still exist as runtime or test truth. +- **Today's failure**: Operators, tests, and contributors can still follow or assert `/admin/tenants...`, `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, `TenantRequiredPermissions::getUrl(...)`, `setTenantPanelContext()`, or dormant tenant-panel provider code even though Specs 279-296 moved the product direction to workspace-first and managed-environment-first routing. +- **User-visible improvement**: Admin operators and maintainers get one product truth: Workspace -> Managed Environments -> Environment Detail / Readiness / Permissions / Diagnostics / Operations. Legacy tenant routes stop competing with canonical routes and cannot return through intended URL handling or stale test fixtures. +- **Smallest enterprise-capable version**: Remove the dormant tenant panel provider if unused, retire active `/admin/tenants...` product routes, centralize canonical managed-environment link generation, reject or safely normalize legacy intended URLs, rename the tenant-panel test helper without an alias, and add guard tests proving legacy surfaces stay retired. +- **Explicit non-goals**: No database rename from `Tenant` to `ManagedEnvironment`, no new product workflow, no compatibility layer for old URLs, no broad localization sweep, no Package Execution, no Guided Operations, no Microsoft provider refactor, no large RBAC refactor, and no new persisted data model. +- **Permanent complexity imported**: One small canonical link helper or extension of an existing helper, targeted guard tests, and one spec-local legacy surface audit. No new table, enum, status family, provider framework, or cross-domain UI framework is introduced. +- **Why now**: Specs 287, 288, and 293 completed prerequisites and guard/stabilization work but repo truth still shows active legacy route and helper surfaces. Delaying this cutover lets the retired tenant product language keep spreading into future specs and tests. +- **Why not local**: The drift is cross-cutting across routes, Filament resource registration, link builders, intended URL resolution, tests, helpers, and operator copy. A local page fix would leave multiple active sources of truth. +- **Approval class**: Cleanup +- **Red flags triggered**: Cross-cutting route/test-helper cleanup and active route retirement. Defense: the scope is bounded to retiring known legacy surfaces and replacing them with existing canonical workspace/environment routes; it adds guard tests, not a new product framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - Retired: `/admin/t`, `/admin/t/...` + - Retired active product surface: `/admin/tenants`, `/admin/tenants/{environment}`, `/admin/tenants/{environment}/edit`, `/admin/tenants/{environment}/memberships`, `/admin/tenants/{environment}/required-permissions`, `/admin/tenants/{environment}/provider-connections...` + - Canonical: `/admin/workspaces/{workspace}/environments` + - Canonical: `/admin/workspaces/{workspace}/environments/{environment}` + - Canonical: `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` + - Canonical: `/admin/workspaces/{workspace}/environments/{environment}/diagnostics` if repo-real or equivalent diagnostics/readiness route exists + - Canonical: `/admin/workspaces/{workspace}/environments/{environment}/access-scopes` if repo-real or equivalent membership/access-scope route exists + - Canonical operations: `/admin/workspaces/{workspace}/operations` and `/admin/workspaces/{workspace}/operations/{run}` +- **Data Ownership**: + - No new persisted entity, table, enum, state, or status family. + - Existing `ManagedEnvironment` / internal `Tenant` model reality may remain technical implementation truth where repo-real. + - Workspace membership remains the role/capability authority. + - Managed environment membership remains narrowing/access-scope only and must not regain role authority. +- **RBAC**: + - Workspace membership is required before revealing workspace or environment surfaces. + - Managed-environment access scope may narrow access but cannot grant role authority. + - Non-member or out-of-scope actors receive 404 (deny-as-not-found). + - Established members missing capability receive 403. + - UI visibility is never authorization; policies/gates remain the server-side truth. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: Any legacy or stale environment hint must be resolved into explicit workspace + managed-environment context before links or pages are shown. If safe resolution is not possible, fall back to workspace home or environment index. +- **Explicit entitlement checks preventing cross-tenant leakage**: Any canonical environment route, operation link, relation/action link, or intended URL normalization must verify workspace entitlement and managed-environment entitlement before revealing tenant-owned records. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: route/link generation, navigation entry points, intended URL handling, Filament resource URLs, action links, test helper context, guard tests, provider/required-permissions launch points, workspace operations links +- **Systems touched**: + - `apps/platform/app/Providers/Filament/TenantPanelProvider.php` + - `apps/platform/bootstrap/providers.php` + - `apps/platform/app/Filament/Resources/TenantResource.php` + - `apps/platform/app/Filament/Pages/TenantDashboard.php` + - `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` + - `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` + - `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` + - `apps/platform/app/Support/OperationRunLinks.php` + - existing workspace/environment route helpers or new bounded `ManagedEnvironmentLinks` + - `apps/platform/tests/Pest.php` + - targeted Feature, Browser, Unit, and Guard tests that still assert old route truth +- **Existing pattern(s) to extend**: current workspace-first admin routes, `OperationRunLinks`, `WorkspaceContext`, existing admin panel context helpers, existing Spec 288/293 guard style, and any repo-real managed-environment route helper. +- **Shared contract / presenter / builder / renderer to reuse**: reuse existing route helpers where they already generate canonical workspace/environment URLs. Introduce `ManagedEnvironmentLinks` only if no repo-real helper owns index/detail/permissions/diagnostics/access-scope URLs. +- **Why the existing shared path is sufficient or insufficient**: The canonical route family already exists for workspace/environment pages and operations. What is insufficient is that old link builders, tests, and intended URL handling still accept or emit retired route families. +- **Allowed deviation and why**: Technical class/model names containing `Tenant` may remain where a database/model rename is out of scope. Provider-specific Microsoft tenant ID copy may remain when it refers to Microsoft Entra tenant identity, not TenantPilot route/product identity. +- **Consistency impact**: Runtime links, Filament resource pages, notifications/toast actions, test helpers, browser tests, and intended URL handling must all converge on the same workspace-first managed-environment routes. +- **Review focus**: Reviewers must verify that no compatibility surface is preserved for `/admin/tenants...`, no `/admin/t...` route returns as active product truth, `TenantPanelProvider` cannot be re-enabled by registration, and no old helper alias remains. + +## OperationRun UX Impact *(mandatory)* + +- **Touches OperationRun start/completion/link UX?**: yes, only for canonical operations links and route generation. +- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` and canonical `admin.operations.index` / `admin.operations.view` routes with explicit workspace context. +- **Delegated start/completion UX behaviors**: Existing `Open operation` / `View operation` behavior remains owned by the shared OperationRun UX path. +- **Local surface-owned behavior that remains**: Environment and provider surfaces may still own local launch inputs and local copy, but not workspace-safe operation URL construction. +- **Queued DB-notification policy**: `N/A` - unchanged. +- **Terminal notification path**: existing central lifecycle mechanism, unchanged. +- **Exception required?**: none. + +## Provider Boundary / Platform Core Check *(mandatory)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: provider-connection route ownership, required-permissions route ownership, managed-environment route naming, operations URL generation, provider diagnostics/readiness links, and provider-owned Microsoft tenant ID copy. +- **Neutral platform terms preserved or introduced**: `workspace`, `managed environment`, `environment`, `provider connection`, `required permissions`, `diagnostics`, `access scope`, `operation`. +- **Provider-specific semantics retained and why**: Microsoft Entra tenant ID and Graph permission terminology may remain where the provider itself is the subject. +- **Why this does not deepen provider coupling accidentally**: The spec removes tenant-first route/product truth from platform core while allowing provider-owned Microsoft terminology only where it describes external identity or permission data. +- **Follow-up path**: document-in-feature for contained technical `Tenant` names that remain because DB/model rename is out of scope; follow-up-spec only if implementation discovers a structural provider/platform boundary gap outside route cutover. + +## UI / Surface Guardrail Impact *(mandatory)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Retire `/admin/tenants...` as active product surface | yes | Native Filament routes/resources, no custom UI planned | navigation, resource links, breadcrumbs, action links | route, page, URL, test context | no | remove or neutralize old surface; do not redesign it | +| Canonical managed-environment links | yes | Native Filament routes and shared route helper | navigation, action links, notifications, operations links | URL/helper only | no | centralize link generation without new UI framework | +| Intended URL rejection/normalization | no direct new UI | N/A | redirect workflow, workspace chooser | session/intended URL | no | behavior-only route safety | +| Test helper rename | no | N/A | test support only | test panel/workspace/environment context | no | helper name must reflect no TenantPanel exists | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Managed environment index/detail | Primary Decision Surface | Operator chooses or inspects the managed environment inside a workspace | workspace, environment identity, readiness/health links, primary next action | domain-specific diagnostics and raw provider detail remain on existing surfaces | Primary because it is the canonical starting point for environment work | Workspace -> Managed Environments -> Environment Detail | removes need to choose between `/admin/tenants` and workspace routes | +| Required permissions / diagnostics / access scopes | Secondary Context Surface | Operator checks readiness, provider health, or access narrowing before acting | current workspace/environment context and status/action affordance | provider-owned diagnostics and evidence remain secondary | Secondary because it supports environment readiness and operation decisions | stays under canonical environment route family | reduces route and copy ambiguity | +| Workspace operations | Secondary Context Surface | Operator follows a run from environment context to operation detail | workspace-scoped run list/detail | run diagnostics and logs remain in operation detail | Secondary because execution truth belongs to Operations | uses existing OperationRun route contract | avoids tenant-scoped operation back links | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Managed environment index/detail | operator-MSP, support-platform | workspace/environment scope, environment name, readiness/action links | provider readiness and access-scope context | raw provider identifiers only where already allowed | `Open environment` or current primary environment action | raw provider payloads and support diagnostics | one canonical environment detail URL | +| Required permissions / diagnostics / access scopes | operator-MSP, support-platform | current environment, required permission state, access-scope state | provider health, missing permissions, narrowing details | Microsoft tenant ID / Graph identifiers only as provider detail | page-owned primary readiness or access action | raw/provider-owned data | no legacy tenant-detail fallback | +| Workspace operations | operator-MSP, support-platform | run status and workspace context | run detail and failure reason | raw logs only where existing gates allow | `View operation` | support/raw detail | operations links always workspace-scoped | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Managed environments | List / Detail | Workspace-scoped environment registry | Open environment | full-row or identifier open to canonical detail | required when table exists | More/detail header | More/detail header with confirmation | `/admin/workspaces/{workspace}/environments` | `/admin/workspaces/{workspace}/environments/{environment}` | workspace + environment | Managed environment | current workspace/environment and readiness links | none | +| Required permissions | Detail / Readiness | Environment-scoped readiness page | Review missing permissions | direct page route | n/a | existing page actions | unchanged; destructive-like actions require confirmation | inherited environment route | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | workspace + environment | Required permissions | permission readiness and safe next action | none | +| Provider connections | List / Detail / Integrations | Tenantless admin resource with neutral scope context | Open provider connection | existing provider resource detail | required where table exists | More/detail header | More/detail header with confirmation | `/admin/provider-connections` | `/admin/provider-connections/{record}` | workspace/environment context through record/query/domain | Provider connection | provider connection and target scope | no tenant-scoped route family | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Managed environments | Workspace operator | Pick the correct environment and continue safely | list/detail | Which managed environment am I working in? | workspace/environment scope, environment identity, current route truth | provider identifiers and low-level diagnostics | readiness, lifecycle, access scope | TenantPilot only unless page-specific action says otherwise | Open environment | existing environment mutations only | +| Required permissions | Workspace operator | Decide whether provider permissions are ready | readiness detail | What permission gap blocks this environment? | missing permission state, provider context, primary remediation path | raw provider scopes and Graph detail | readiness and verification | Microsoft tenant only when explicitly remediating provider permission | Review required permissions | none added | +| Provider connections | Workspace operator | Inspect or manage provider connection | integration resource | Which provider connection applies to this scope? | provider connection, neutral target scope | provider-owned profile detail | lifecycle, authorization, target scope | TenantPilot record / Microsoft provider depending on existing action | Open provider connection | existing destructive actions only | +| Operations | Workspace operator | Follow execution truth | operations list/detail | What happened or is running in this workspace? | run status, workspace scope, environment filter when present | low-level run diagnostics | lifecycle, outcome, progress | execution record only | View operation | none added | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no application persistence; one spec-local `legacy-surface-audit.md` artifact is used as implementation evidence. +- **New abstraction?**: yes, only if no existing canonical link helper exists. The allowed abstraction is a bounded `ManagedEnvironmentLinks` helper for route generation. +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: legacy tenant routes and helpers still act as product truth and can resurrect retired surfaces. +- **Existing structure is insufficient because**: direct calls to TenantResource/TenantDashboard/TenantRequiredPermissions and old intended URL handling are scattered; route truth is not centralized enough for guard tests to enforce cleanly. +- **Narrowest correct implementation**: extend an existing helper if present; otherwise add one small link helper that only maps current workspace/environment objects to existing named routes. +- **Ownership cost**: low; one helper plus guard tests must be maintained when route names change. +- **Alternative intentionally rejected**: preserving redirects or aliases for `/admin/tenants...` or `/admin/t...`; that would keep the legacy surface active. +- **Release truth**: current-release cutover cleanup in a pre-production environment. + +### Compatibility posture + +This feature assumes the repo's pre-production lean doctrine. + +Backward compatibility, legacy aliases, route shims, old helper aliases, dual routing, and compatibility-specific tests are out of scope unless a safe canonical URL can be uniquely resolved and the spec/plan is updated with an explicit exception. The default behavior for unsafe legacy paths is 404 or canonical workspace fallback, not silent redirect compatibility. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature guard tests for route/link/intended URL contracts; Feature/Filament tests for resource/page link changes; Unit tests for helper/link resolver behavior; Browser only if visible navigation flows are touched. +- **Validation lane(s)**: targeted Feature guards, Workspaces, ProviderConnections, RequiredPermissions, Filament, Spec 288 guard pack, Spec 293 cutover lane, and optional Browser smoke if navigation is visibly changed. +- **Why this classification and these lanes are sufficient**: The change is route/link/test-helper cutover, not a new business workflow. Guard tests prove retired paths stay dead; focused resource/page tests prove canonical replacements work. +- **New or expanded test families**: new/expanded guard tests under `tests/Feature/Guards` and `tests/Feature/Workspaces`; no new permanent test lane. +- **Fixture / helper cost impact**: `setTenantPanelContext()` is replaced by an explicit admin/workspace/environment helper. The new helper must not make expensive provider, browser, or full workspace defaults implicit. +- **Heavy-family visibility / justification**: none by default. Heavy/browser tests only run when existing visible flows are touched. +- **Special surface test profile**: `standard-native-filament`, `global-context-shell`, `route-contract`, and `browser-smoke` only when UI navigation is touched. +- **Standard-native relief or required special coverage**: ordinary Pest/Filament coverage is sufficient unless a browser-facing route/navigation flow changes. +- **Reviewer handoff**: Reviewers must confirm Filament v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, globally searchable resources have Edit/View pages or global search disabled, destructive actions still use `->action(...)`, `->requiresConfirmation()`, and authorization, asset strategy is unchanged unless documented, and tests cover mutated pages/actions via Livewire/Filament where applicable. +- **Budget / baseline / trend impact**: targeted guards grow; no full-suite budget change is planned. Any material lane runtime drift must be documented in the active PR/spec close-out. +- **Escalation needed**: document-in-feature for contained old technical names; follow-up-spec for structural DB/model rename or broader localization. +- **Active feature PR close-out entry**: Guardrail / Route Cutover / Smoke Coverage as applicable. +- **Planned validation commands**: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact tests/Feature/Guards +./vendor/bin/sail artisan test --compact tests/Feature/Workspaces +./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections +./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions +./vendor/bin/sail artisan test --compact tests/Feature/Filament +./vendor/bin/sail bin pint --dirty --format agent +``` + +### Required Regression Proof Pack + +The implementation close-out must record exact results for the focused Spec 297 proof: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php \ + tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php \ + tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php \ + tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php \ + tests/Feature/ProviderConnections/LegacyRedirectTest.php \ + tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php \ + tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php \ + tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php \ + tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php +``` + +The existing Spec 288 guard pack must also remain green: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Feature/Guards/Spec288NoLegacyRouteAndHelperGuardTest.php \ + tests/Feature/Guards/Spec288ProviderCoreAndRoleAuthorityGuardTest.php \ + tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php \ + tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php \ + tests/Feature/Guards/BrowserLaneIsolationTest.php \ + tests/Feature/Guards/CiLaneFailureClassificationContractTest.php \ + tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php +``` + +If visible navigation flows are touched, run browser smoke: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact \ + tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php \ + tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php +``` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Legacy Tenant Routes Stay Dead (Priority: P1) + +As a workspace operator, I must not be routed into retired tenant-panel or tenant-resource product surfaces. + +**Why this priority**: Retired routes are the direct source of product-truth drift. + +**Independent Test**: Request `/admin/t/example`, `/admin/t/example/operations`, `/admin/t/example/provider-connections`, `/admin/tenants`, `/admin/tenants/{environment}`, and legacy provider/required-permissions paths and verify they are not active product pages. + +**Acceptance Scenarios**: + +1. **Given** an authenticated workspace member, **When** they request `/admin/t/example`, **Then** the response is 404. +2. **Given** an authenticated workspace member, **When** they request `/admin/tenants/{environment}/provider-connections`, **Then** the response is 404 unless a documented safe canonical resolution exists. +3. **Given** route:list output, **When** it is scanned, **Then** no active `/admin/t...` route exists and `/admin/tenants...` is not an active product surface. + +--- + +### User Story 2 - Canonical Managed Environment Links Are The Only Runtime Links (Priority: P1) + +As an operator following links from dashboards, notifications, provider connections, evidence, reviews, or operations, I need every environment link to resolve through workspace-first managed-environment routes. + +**Why this priority**: Old link generation silently revives legacy surfaces even after routes are hidden. + +**Independent Test**: Render or call runtime link builders for environment detail, required permissions, diagnostics, access scopes, provider connections, and operations and verify generated URLs contain `/admin/workspaces/{workspace}/environments...` or tenantless provider/operations canonical routes, never `/admin/tenants...` or `/admin/t...`. + +**Acceptance Scenarios**: + +1. **Given** a workspace and managed environment, **When** `ManagedEnvironmentLinks::viewUrl($environment)` or the repo-real equivalent is called, **Then** the URL points to canonical environment detail. +2. **Given** an environment with required permissions, **When** the page action/back link is rendered, **Then** the link points to canonical environment required-permissions route. +3. **Given** an operation for an environment, **When** a related link is generated, **Then** the operation URL is workspace-scoped. + +--- + +### User Story 3 - Intended URLs Cannot Resurrect Legacy Surfaces (Priority: P1) + +As a user returning after workspace selection or login, I must never land on retired tenant routes through stored intended URLs. + +**Why this priority**: Intended URL persistence can bypass navigation cleanup and silently re-enable old route truth. + +**Independent Test**: Store legacy intended URLs and verify the resolver rejects them, normalizes `/admin/operations` to workspace operations when possible, and falls back to workspace home or environment index when safe resolution is impossible. + +**Acceptance Scenarios**: + +1. **Given** `/admin/t/example/provider-connections` as an intended URL, **When** the workspace redirect resolver runs, **Then** it does not return or persist that URL. +2. **Given** `/admin/operations` as an intended URL and a known workspace, **When** the resolver runs, **Then** it returns `/admin/workspaces/{workspace}/operations`. +3. **Given** `/admin/tenants/example/required-permissions` without safe workspace/environment resolution, **When** the resolver runs, **Then** it falls back to workspace home or environment index. + +--- + +### User Story 4 - Test Harness Uses Admin Workspace/Environment Context (Priority: P2) + +As a maintainer, I need tests to express the current admin panel and managed-environment context rather than reviving TenantPanel vocabulary. + +**Why this priority**: Old helper names keep the retired panel as a mental and test-runtime model. + +**Independent Test**: `rg "setTenantPanelContext|panel:\\s*'tenant'|panel:\\s*\"tenant\"" apps/platform/tests` returns no active helper or non-guard use; all migrated tests use a new admin/workspace/environment context helper. + +**Acceptance Scenarios**: + +1. **Given** `tests/Pest.php`, **When** it is scanned, **Then** `setTenantPanelContext` is absent. +2. **Given** a test that needs environment context, **When** it sets context, **Then** it uses `setAdminEnvironmentContext()` or the chosen canonical helper. +3. **Given** guard tests, **When** an old helper name is reintroduced, **Then** the guard fails. + +--- + +### User Story 5 - Narrowing-Only Environment Access Remains Intact (Priority: P2) + +As a security reviewer, I need managed-environment memberships to remain access-scope/narrowing-only and never regain role authority. + +**Why this priority**: Route cutover must not backslide into tenant-scope role authority while replacing test fixtures. + +**Independent Test**: RBAC tests prove workspace membership is the only role-bearing truth and managed-environment scope cannot grant role/capability authority. + +**Acceptance Scenarios**: + +1. **Given** a user with managed-environment scope but no workspace capability, **When** they access a protected action, **Then** capability denial remains 403 after membership is established or 404 when not entitled. +2. **Given** a managed-environment membership row with legacy/placeholder role data, **When** authorization runs, **Then** the role value is ignored as authority. + +## Functional Requirements + +- **FR-297-001**: `apps/platform/app/Providers/Filament/TenantPanelProvider.php` MUST be deleted if it is not runtime-registered and not required by runtime code. If deletion proves impossible, `legacy-surface-audit.md` MUST document the true dependency and the implementation MUST still prove it cannot be registered or route `/admin/t...`. +- **FR-297-002**: `apps/platform/bootstrap/providers.php` MUST NOT register `TenantPanelProvider`. +- **FR-297-003**: `/admin/t`, `/admin/t/...`, and nested legacy tenant-panel paths MUST return 404 or be absent from route:list. Default behavior is 404, not redirect. +- **FR-297-004**: `/admin/tenants...` MUST no longer be active product truth. TenantResource list/view/edit/memberships routes MUST be removed, moved out of auto-discovery, or otherwise made non-product routes with explicit guard proof. +- **FR-297-005**: Canonical managed-environment URLs MUST exist for environment index, detail, required permissions/readiness, diagnostics/provider health, access scopes/membership narrowing, and workspace operations, reusing repo-real routes where present. +- **FR-297-006**: Runtime code MUST NOT generate active product links through `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, or `TenantRequiredPermissions::getUrl(...)` unless the target class has been changed to emit canonical workspace/environment routes and guard tests prove no `/admin/tenants...` or `/admin/t...` URL. +- **FR-297-007**: The implementation MUST introduce or extend one canonical link owner for managed-environment URLs, such as `ManagedEnvironmentLinks`, instead of scattering route-name literals across runtime surfaces. +- **FR-297-008**: `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, or their repo-real equivalents MUST reject `/admin/t...`, `/admin/tenants...`, `/admin/tenants/*/required-permissions`, `/admin/tenants/*/provider-connections`, and external URLs as final intended destinations. +- **FR-297-009**: Legacy `/admin/operations` intended URLs MUST normalize to workspace-scoped operations when a workspace is known, or fall back safely when not. +- **FR-297-010**: Legacy required-permissions URLs under `/admin/tenants/{environment}/required-permissions` MUST not return 200 as active product pages. +- **FR-297-011**: Legacy provider-connection URLs under `/admin/tenants/{environment}/provider-connections...` MUST not return 200 as active product pages. +- **FR-297-012**: `setTenantPanelContext()` MUST be removed or renamed with no alias under the old name. The replacement helper MUST express admin + workspace + managed-environment context. +- **FR-297-013**: Runtime and test copy touched by this cutover MUST use managed-environment/environment product language instead of tenant-first labels, except for Microsoft/provider-specific tenant ID terminology, technical model names, migrations, historical specs, or audit history. +- **FR-297-014**: Guard tests MUST fail if `TenantPanelProvider` is registered, `/admin/t...` becomes routable, `/admin/tenants...` is revived as product truth, old helper names return, or runtime URL generation emits legacy tenant paths. +- **FR-297-015**: Workspace-first RBAC MUST remain intact: workspace membership carries role/capability authority, managed-environment membership narrows access only, and non-member scope remains deny-as-not-found. + +## Non-Functional Requirements + +- **NFR-297-001**: No new persisted storage, migration, compatibility shim, or dual-read/dual-write path. +- **NFR-297-002**: Route/link guards must be deterministic and actionable, with failure messages that name the forbidden path/helper and owning file. +- **NFR-297-003**: New helpers must be small, explicit, and easy to delete or rename if the route contract changes. +- **NFR-297-004**: Test updates must not broaden heavy/browser lanes unless the touched surface requires it. +- **NFR-297-005**: Any remaining technical `Tenant` references must be documented as allowed or listed as follow-up. + +## Acceptance Criteria + +- **AC-297-001**: No runtime provider file or provider registration can re-enable the tenant panel. +- **AC-297-002**: `/admin/t...` is not routable as product truth and is guarded. +- **AC-297-003**: `/admin/tenants...` is retired as active product truth; no navigation, intended URL, runtime link, or test helper depends on it. +- **AC-297-004**: One canonical managed-environment link contract covers index, detail, readiness/permissions, diagnostics, access scopes, and workspace operations where repo-real routes exist. +- **AC-297-005**: Intended URL handling cannot return retired tenant routes. +- **AC-297-006**: Runtime links no longer generate old TenantResource/TenantPanel URLs. +- **AC-297-007**: `setTenantPanelContext()` is gone with no alias. +- **AC-297-008**: Provider and required-permissions tenant-scoped legacy routes do not return 200. +- **AC-297-009**: Managed-environment access scopes remain narrowing-only. +- **AC-297-010**: Spec 288 and Spec 293 guard/stabilization proof remains green after the cutover. +- **AC-297-011**: Pint dirty passes. + +## Success Criteria + +- **SC-297-001**: Route-list and guard tests prove no active `/admin/t...` route and no active product `/admin/tenants...` route family. +- **SC-297-002**: Source scans in runtime code find no unallowed `/admin/tenants`, `/admin/t/`, `TenantResource::getUrl`, `TenantDashboard::getUrl`, `TenantRequiredPermissions::getUrl`, or `setTenantPanelContext` references. +- **SC-297-003**: Focused route/link/intended URL tests pass without compatibility redirects. +- **SC-297-004**: Final implementation summary can state: `Managed Environment canonical cutover complete; legacy tenant surfaces retired.` + +## Risks + +- **R-297-001**: Some old tests may still assert historical `/admin/tenants...` behavior from Spec 080/143/147. Mitigation: classify as historical tests only if moved/retired or update them to current product truth. +- **R-297-002**: TenantResource may still be globally searchable. Mitigation: retire it from discovery or disable global search unless canonical Edit/View routes exist. +- **R-297-003**: Link helper creation could become over-generalized. Mitigation: helper may only map existing workspace/environment routes and must not become a routing framework. +- **R-297-004**: Intended URL normalization can create unsafe cross-workspace redirects. Mitigation: require workspace/environment entitlement checks and fallback when resolution is ambiguous. + +## Assumptions + +- The product is still pre-production under LEAN-001, so compatibility routes and legacy aliases are not required. +- Internal `Tenant` class/table names may remain technical implementation detail until a separate DB/model rename spec exists. +- Existing canonical workspace/environment routes are the preferred target and must be reused rather than duplicated. +- Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. +- No new asset registration is planned. If implementation unexpectedly registers Filament assets, deploy notes must include `cd apps/platform && php artisan filament:assets`. + +## Final Output Required For Implementation + +At implementation close-out, report: + +1. Commands run and result. +2. Deleted legacy code and why deletion was safe. +3. Retired route families, new behavior, and guard. +4. Canonical replacements for old usage. +5. Remaining legacy references, why allowed, and follow-up if any. +6. Test/lane results. +7. Final decision, exactly one of: + - `Managed Environment canonical cutover complete; legacy tenant surfaces retired.` + - `Blocked by true runtime dependency on legacy tenant surface.` + - `Incomplete; active legacy tenant routes remain.` diff --git a/specs/297-managed-environment-canonical-route-cutover/tasks.md b/specs/297-managed-environment-canonical-route-cutover/tasks.md new file mode 100644 index 00000000..eb863372 --- /dev/null +++ b/specs/297-managed-environment-canonical-route-cutover/tasks.md @@ -0,0 +1,190 @@ +--- +description: "Task list for Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement" +--- + +# Tasks: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/` +**Prerequisites**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `legacy-surface-audit.md`, `contracts/managed-environment-canonical-route-contract.md`, `checklists/requirements.md` + +**Tests**: Required (Pest) for route/link/intended URL/helper changes. Browser smoke is required only if visible navigation flows are touched. +**Operations**: No new `OperationRun` behavior. Existing operation links must stay workspace-scoped through the shared OperationRun link contract. +**RBAC**: Workspace membership remains role/capability authority. Managed-environment membership remains narrowing-only. Non-member/out-of-scope returns 404; established member missing capability returns 403. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. No new panel. No asset-strategy change unless explicitly documented. +**Review Outcome**: preparation-ready +**Workflow Outcome**: keep +**Test-governance Outcome**: keep + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for each changed behavior. +- [x] New or changed tests stay in the smallest honest family; browser/heavy-governance additions are explicit. +- [x] Shared helpers, factories, seeds, fixtures, provider setup, workspace context, session state, and capability defaults stay cheap by default. +- [x] Planned validation commands cover route/link/helper/intended URL changes without pulling in unrelated lane cost. +- [x] The declared surface test profile or `standard-native-filament` relief is explicit. +- [x] Any material runtime, budget, baseline, trend, or escalation note is recorded in the active spec close-out. + +## Phase 1: Safety Gate And Baseline Audit + +**Purpose**: Start from a clean branch and refresh repo truth before runtime edits. + +- [x] T001 Run `git status --short --branch`, `git diff --stat`, and `git log -1 --oneline` in `/Users/ahmeddarrazi/Documents/projects/wt-plattform`; stop if unrelated uncommitted changes are present. +- [x] T002 Confirm the implementation branch is `297-managed-environment-canonical-route-cutover` or an isolated session branch derived from it. +- [x] T003 Review `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/memory/constitution.md`, this spec package, and related Specs 287, 288, 293, and 296 as context only. +- [x] T004 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations"`. +- [x] T005 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && rg "TenantPanelProvider|panel:\\s*'tenant'|panel:\\s*\\\"tenant\\\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\\.operations" . --glob '!vendor' --glob '!node_modules'`. +- [x] T006 Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md` with every active runtime, test, copy, historical, provider-specific, and allowed technical finding before editing application code. +- [x] T007 Confirm the scope boundary remains explicit: no DB/model rename, no compatibility surface, no Package Execution, no Guided Operations, no broad localization, no broad RBAC rewrite, and no TenantPanel restoration. + +## Phase 2: Remove Or Permanently Neutralize TenantPanelProvider + +**Goal**: Ensure the retired tenant panel cannot be reactivated as runtime code. + +- [x] T008 [P] Inspect `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`, and current tests that reference `TenantPanelProvider`. +- [x] T009 Add or extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php` to assert `TenantPanelProvider` is not registered, no `/admin/t...` route exists, and no active panel provider with `id('tenant')` exists. +- [x] T010 Delete `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/TenantPanelProvider.php` if no true runtime dependency exists. +- [x] T011 If T010 is blocked by a true dependency, document the dependency in `legacy-surface-audit.md` and still guard against registration or route activation. +- [x] T012 Update tests that directly inspect the provider file so they assert registration and route behavior instead of requiring the file to exist. +- [x] T013 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php`. + +## Phase 3: Establish Canonical Managed Environment Link Contract + +**Goal**: Route all environment links through one canonical owner. + +- [x] T014 [P] Locate existing managed-environment route/helper owners in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app` before creating a new helper. +- [x] T015 [P] Audit current route names for environment index, detail, required permissions, diagnostics, access scopes, provider connections, and workspace operations. +- [x] T016 Add or extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php` to prove canonical link generation for index/detail/required-permissions/diagnostics/access-scopes/operations. +- [x] T017 Create or extend the bounded canonical link helper, such as `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/ManagedEnvironmentLinks.php`, only if no repo-real helper already owns this contract. +- [x] T018 Ensure every helper method receives enough workspace/environment context to avoid ambiguous cross-workspace URL generation. +- [x] T019 Add tests proving generated canonical URLs never contain `/admin/tenants` or `/admin/t/`. +- [x] T020 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php`. + +## Phase 4: Replace Runtime Legacy URL Generation + +**Goal**: Stop runtime links from emitting TenantResource/TenantDashboard/TenantRequiredPermissions URLs as product truth. + +- [x] T021 [P] Audit runtime occurrences of `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, and `TenantRequiredPermissions::getUrl(...)` under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app`. +- [x] T022 Replace environment detail links with the canonical managed-environment link helper or repo-real equivalent. +- [x] T023 Replace required-permissions links with canonical workspace/environment required-permissions URLs. +- [x] T024 Replace diagnostics/provider-health/access-scope links with canonical workspace/environment URLs where repo-real routes exist. +- [x] T025 Replace provider-connection tenant-detail backlinks with tenantless provider-connection URLs or canonical environment detail links, depending on the page owner. +- [x] T026 Replace dashboard/workspace overview/action links that still point at `/admin/tenants...` or TenantDashboard legacy routes. +- [x] T027 Ensure `OperationRunLinks` and related navigation still generate workspace-scoped operations URLs and do not reintroduce tenant-scoped operation paths. +- [x] T028 Update runtime tests around notifications, toast actions, review detail links, evidence links, decision-register links, provider connection links, required-permissions links, workspace dashboard links, governance inbox links, and tenant dashboard/back links as directly touched. + +## Phase 5: Retire Active `/admin/tenants...` Product Routes + +**Goal**: Remove `/admin/tenants...` as active product truth. + +- [x] T029 Add or extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php` to assert `/admin/tenants`, `/admin/tenants/{environment}`, `/admin/tenants/{environment}/edit`, and `/admin/tenants/{environment}/memberships` are not active product pages. +- [x] T030 Decide the narrowest repo-real retirement strategy for TenantResource: remove active route registration, move it out of auto-discovery, disable it as a product surface, or replace it with canonical managed-environment routing. +- [x] T031 Apply the retirement strategy to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantResource.php` and related registration/discovery owners. +- [x] T032 Update global search behavior for any retired or moved resource: globally searchable resources must have Edit/View pages, otherwise disable global search. +- [x] T033 Ensure no navigation item, table action, header action, empty-state action, notification, or redirect uses `/admin/tenants...` as an active product destination. +- [x] T034 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan route:list | rg "admin/tenants"` and classify any remaining route in `legacy-surface-audit.md`. +- [x] T035 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php`. + +## Phase 6: Intended URL Legacy Rejection + +**Goal**: Prevent old paths from surviving login/workspace-selection redirects. + +- [x] T036 [P] Inspect `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` and any repo-real `WorkspaceIntendedUrl` owner. +- [x] T037 Add `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php` covering retired tenant-panel URLs, retired tenant-resource URLs, legacy operations normalization, unsafe fallback, and external URL blocking. +- [x] T038 Update intended URL handling to reject `/admin/t`, `/admin/t/*`, `/admin/tenants`, `/admin/tenants/*`, `/admin/tenants/*/required-permissions`, and `/admin/tenants/*/provider-connections`. +- [x] T039 Normalize legacy `/admin/operations` to workspace-scoped operations only when a workspace is known and authorized. +- [x] T040 Fall back to workspace home or environment index when legacy URL resolution is ambiguous or unsafe. +- [x] T041 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php`. + +## Phase 7: Required Permissions And Provider Connections Canonicalization + +**Goal**: Keep tenant-scoped required-permissions and provider-connection URLs retired. + +- [x] T042 [P] Audit `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, provider-connection resources/pages, and related tests. +- [x] T043 Update required-permissions runtime links and tests to use `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` or the repo-real canonical equivalent. +- [x] T044 Update provider-connection links and tests so provider connections remain tenantless admin resources with neutral workspace/environment scope context. +- [x] T045 Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ProviderConnections/LegacyRedirectTest.php` so old tenant-scoped provider-connection URLs assert not-found rather than compatibility redirect. +- [x] T046 Ensure old `/admin/tenants/{environment}/required-permissions` does not return 200 and is not used in link generation. +- [x] T047 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions tests/Feature/ProviderConnections`. + +## Phase 8: Rename Tenant-Panel Test Helper + +**Goal**: Remove `setTenantPanelContext()` with no alias. + +- [x] T048 [P] Audit every `setTenantPanelContext()` usage under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests`. +- [x] T049 Add or extend a guard that asserts `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Pest.php` does not contain `setTenantPanelContext`. +- [x] T050 Rename the helper in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Pest.php` to the selected canonical name, such as `setAdminEnvironmentContext()` or `setManagedEnvironmentContext()`. +- [x] T051 Ensure the replacement helper sets admin panel + workspace + managed-environment context and documents that no TenantPanel exists. +- [x] T052 Update every test call site to the new helper name. +- [x] T053 Do not leave a compatibility alias under `setTenantPanelContext`. +- [x] T054 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && rg "setTenantPanelContext|panel:\\s*'tenant'|panel:\\s*\\\"tenant\\\"" tests` and classify only explicit retired-behavior guards if any remain. + +## Phase 9: RBAC And Access-Scope Authority Check + +**Goal**: Preserve workspace-first RBAC while route/test fixtures move. + +- [x] T055 Confirm tests touching managed-environment memberships do not treat `managed_environment_memberships.role` as capability authority. +- [x] T056 Update stale `change_role` or scope-role authority expectations to workspace-membership role/capability truth. +- [x] T057 Confirm provider-connection and environment access policies still enforce workspace membership first and managed-environment narrowing second. +- [x] T058 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php`. + +## Phase 10: Copy Cleanup In Touched Active Surfaces + +**Goal**: Avoid tenant-first product copy in files touched by this cutover. + +- [x] T059 Replace tenant-first user-facing copy in touched active surfaces, including `Tenant dashboard`, `Tenant detail`, `Open tenant detail`, `Select tenant`, `Tenant scope`, `Managed tenant`, `Remove tenant`, `Restore tenant`, and `Tenant memberships`. +- [x] T060 Keep provider-specific Microsoft tenant ID copy, technical model names, migrations, historical specs, and audit historical values where correct. +- [x] T061 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && rg "Tenant dashboard|Tenant detail|Open tenant|Select tenant|Tenant scope|Remove tenant|Restore tenant|Tenant memberships" app resources lang tests`. +- [x] T062 Record every remaining touched-file hit in `legacy-surface-audit.md` as allowed, provider-specific, technical/internal, historical, or follow-up. + +## Phase 11: Regression Proof Pack + +**Goal**: Prove the new cutover and existing guard packs stay green. + +- [x] T063 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php tests/Feature/ProviderConnections/LegacyRedirectTest.php tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php`. +- [x] T064 Run the existing Spec 288 guard pack exactly as listed in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/spec.md`. +- [x] T065 Run the existing Spec 293 cutover/stabilization proof if any touched tests overlap with Spec 293 seams. +- [x] T066 If visible navigation or browser flow files changed, run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php`. + +## Phase 12: Broad Validation And Close-Out + +**Goal**: Finish with focused broad lanes, formatting, and the required decision. + +- [x] T067 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards`. +- [x] T068 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces`. +- [x] T069 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections`. +- [x] T070 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions`. +- [x] T071 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament`. +- [x] T072 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T073 Run `git diff --check` from `/Users/ahmeddarrazi/Documents/projects/wt-plattform`. +- [x] T074 Update `legacy-surface-audit.md` with fixed/remaining status and allowed references. +- [x] T075 Confirm the Filament output contract: Livewire v4.0+ compliance, provider registration in `bootstrap/providers.php`, global-search handling for retired resources, destructive-action confirmation/authorization unchanged, asset strategy unchanged or deploy note added, and tests cover pages/actions/widgets through Livewire/Filament where applicable. +- [x] T076 Write the final implementation summary with commands run, deleted legacy code, retired routes, canonical replacements, remaining legacy references, test results, and one final decision string. + +## Dependencies & Execution Order + +- Phase 1 blocks all runtime edits. +- Phase 2 and Phase 5 are high-risk route/provider changes and should happen before broad runtime link replacement is considered complete. +- Phase 3 can begin after Phase 1 and should land before most runtime replacement work in Phase 4. +- Phase 6 depends on enough canonical route/link contract from Phase 3 to choose safe fallbacks. +- Phase 7 depends on Phase 3 and Phase 5 route decisions. +- Phase 8 can run alongside later route replacement but must finish before final guards. +- Phase 9 must run after helper/test fixture changes that might affect RBAC setup. +- Phase 10 applies only to files touched by implementation. +- Phases 11 and 12 close the proof loop. + +## Parallel Execution Examples + +- T008 and T014 can run in parallel because provider deletion and link helper discovery inspect different owners. +- T021 and T036 can run in parallel after Phase 1 because runtime link audit and intended URL audit touch different seams. +- T042 and T048 can run in parallel because required-permissions/provider-connection audit and helper-call-site audit are separate. +- T059 can run after any touched-file set is known; it should not begin a repo-wide localization sweep. + +## Explicit Follow-Ups / Out of Scope + +- Database/model rename from `Tenant` to `ManagedEnvironment` +- Broad tenant-to-environment localization sweep +- Package Execution Contract +- Guided Operations +- Microsoft Provider Refactor +- New provider abstraction or route framework +- Full-suite repair unless separately requested -- 2.45.2