diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index a9ba892..cfac15a 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -347,7 +347,7 @@ public function content(Schema $schema): Schema SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') - ->visible(fn (): bool => $this->managedTenant instanceof Tenant) + ->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive()) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ? null @@ -662,7 +662,7 @@ private function verificationStatus(): string continue; } - if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied', 'provider_consent_missing'], true)) { + if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) { return 'blocked'; } } diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index a126449..2808630 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -26,7 +26,6 @@ use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use BackedEnum; -use Carbon\CarbonImmutable; use DateTimeZone; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -50,7 +49,6 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -58,6 +56,10 @@ class BackupScheduleResource extends Resource { + protected static ?string $model = BackupSchedule::class; + + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php index c0baa2f..3b112bc 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php @@ -8,6 +8,10 @@ use App\Support\Badges\BadgeRenderer; use App\Support\OperationCatalog; use App\Support\OperationRunLinks; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -20,6 +24,16 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager protected static ?string $title = 'Executions'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.'); + } + public function table(Table $table): Table { return $table @@ -32,7 +46,7 @@ public function table(Table $table): Table Tables\Columns\TextColumn::make('type') ->label('Type') - ->formatStateUsing(fn (string $state): string => OperationCatalog::label($state)), + ->formatStateUsing([OperationCatalog::class, 'label']), Tables\Columns\TextColumn::make('status') ->badge() diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index e92e30c..c64e6f0 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -43,6 +43,8 @@ class BackupSetResource extends Resource { protected static ?string $model = BackupSet::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 968c6e6..b612330 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -38,6 +38,8 @@ class FindingResource extends Resource { protected static ?string $model = Finding::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string|UnitEnum|null $navigationGroup = 'Drift'; diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index a159bbe..408ecbf 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -37,6 +37,8 @@ class InventoryItemResource extends Resource { protected static ?string $model = InventoryItem::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static ?string $cluster = InventoryCluster::class; protected static ?int $navigationSort = 1; diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index a489753..84f7e54 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -32,6 +32,8 @@ class InventorySyncRunResource extends Resource { protected static ?string $model = InventorySyncRun::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static bool $shouldRegisterNavigation = true; protected static ?string $cluster = InventoryCluster::class; diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 96ca454..86cd290 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -52,6 +52,8 @@ class PolicyResource extends Resource { protected static ?string $model = Policy::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 15f1eeb..b2d2234 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -51,6 +51,8 @@ class PolicyVersionResource extends Resource { protected static ?string $model = PolicyVersion::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 1b43099..7abd062 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -18,8 +18,8 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; -use App\Support\Rbac\UiEnforcement; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Rbac\UiEnforcement; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; @@ -93,7 +93,9 @@ protected static function resolveScopedTenant(): ?Tenant ->first(); } - return Tenant::current(); + $filamentTenant = \Filament\Facades\Filament::getTenant(); + + return $filamentTenant instanceof Tenant ? $filamentTenant : null; } private static function resolveTenantExternalIdFromLivewireRequest(): ?string @@ -772,9 +774,13 @@ public static function getEloquentQuery(): Builder return $query->whereRaw('1 = 0'); } + if ($tenantId === null) { + return $query->whereRaw('1 = 0'); + } + return $query ->where('workspace_id', (int) $workspaceId) - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->where('tenant_id', $tenantId) ->latest('id'); } @@ -792,6 +798,10 @@ public static function getPages(): array */ public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string { + if (array_key_exists('tenant', $parameters) && blank($parameters['tenant'])) { + unset($parameters['tenant']); + } + if (! array_key_exists('tenant', $parameters)) { if ($tenant instanceof Tenant) { $parameters['tenant'] = $tenant->external_id; @@ -802,6 +812,20 @@ public static function getUrl(?string $name = null, array $parameters = [], bool if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) { $parameters['tenant'] = $resolvedTenant->external_id; } + + $record = $parameters['record'] ?? null; + + if (! array_key_exists('tenant', $parameters) && $record instanceof ProviderConnection) { + $recordTenant = $record->tenant; + + if (! $recordTenant instanceof Tenant) { + $recordTenant = Tenant::query()->whereKey($record->tenant_id)->first(); + } + + if ($recordTenant instanceof Tenant) { + $parameters['tenant'] = $recordTenant->external_id; + } + } } $panel ??= 'admin'; diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index ebea9ad..5662e27 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -630,9 +630,9 @@ protected function getHeaderActions(): array $hadCredentials = $record->credential()->exists(); $previousStatus = (string) $record->status; - $status = $hadCredentials ? 'connected' : 'error'; - $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; - $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $errorReasonCode = null; + $errorMessage = null; $record->update([ 'status' => $status, @@ -669,8 +669,8 @@ protected function getHeaderActions(): array if (! $hadCredentials) { Notification::make() - ->title('Connection enabled (credentials missing)') - ->body('Add credentials before running checks or operations.') + ->title('Connection enabled (needs consent)') + ->body('Grant admin consent before running checks or operations.') ->warning() ->send(); diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index d0f4e32..6b74f83 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -36,6 +36,7 @@ use Filament\Actions\ActionGroup; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -60,6 +61,17 @@ class RestoreRunResource extends Resource { protected static ?string $model = RestoreRun::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + + public static function shouldRegisterNavigation(): bool + { + if (Filament::getCurrentPanel()?->getId() === 'admin') { + return false; + } + + return parent::shouldRegisterNavigation(); + } + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 6bad2e4..578ad70 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -7,7 +7,6 @@ use App\Http\Controllers\RbacDelegatedAuthController; use App\Jobs\BulkTenantSyncJob; use App\Jobs\SyncPoliciesJob; -use App\Jobs\SyncRoleDefinitionsJob; use App\Models\EntraGroup; use App\Models\EntraRoleDefinition; use App\Models\ProviderConnection; @@ -18,6 +17,7 @@ use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\RoleDefinitionsSyncService; +use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacOnboardingService; use App\Services\OperationRunService; @@ -50,9 +50,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use Throwable; use UnitEnum; class TenantResource extends Resource @@ -982,6 +980,7 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('scope') === 'scope_group') ->required(fn (Get $get) => $get('scope') === 'scope_group') + ->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value)) ->noSearchResultsMessage('No security groups found') @@ -1008,6 +1007,7 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('group_mode') === 'existing') ->required(fn (Get $get) => $get('group_mode') === 'existing') + ->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value)) ->noSearchResultsMessage('No security groups found') @@ -1046,8 +1046,10 @@ public static function rbacAction(): Actions\Action abort(403); } - $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); - $token = Cache::get($cacheKey); + $userCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), null); + $sessionCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); + + $token = Cache::get($sessionCacheKey) ?? Cache::get($userCacheKey); if (! $token) { Notification::make() @@ -1072,7 +1074,8 @@ public static function rbacAction(): Actions\Action $result = $service->run($record, $data, $user, $token); - Cache::forget($cacheKey); + Cache::forget($sessionCacheKey); + Cache::forget($userCacheKey); if ($result['status'] === 'success') { Notification::make() @@ -1232,11 +1235,7 @@ public static function roleSearchHelper(?Tenant $tenant): ?string return null; } - $exists = EntraRoleDefinition::query() - ->where('tenant_id', $tenant->getKey()) - ->exists(); - - return $exists ? null : 'Role definitions not synced yet. Use “Sync now” to load.'; + return static::delegatedToken($tenant) === null ? 'Login to search roles' : null; } /** @@ -1244,9 +1243,61 @@ public static function roleSearchHelper(?Tenant $tenant): ?string */ public static function roleSearchOptions(?Tenant $tenant, string $search): array { + $token = static::delegatedToken($tenant); + + if ($token !== null) { + return static::searchRoleDefinitionsDelegated($tenant, $search, $token); + } + return static::searchRoleDefinitions($tenant, $search); } + /** + * @return array + */ + private static function searchRoleDefinitionsDelegated(?Tenant $tenant, string $search, string $token): array + { + if (! $tenant || mb_strlen($search) < 2) { + return []; + } + + $needle = mb_strtolower($search); + + /** @var GraphClientInterface $graph */ + $graph = app(GraphClientInterface::class); + + $response = $graph->request('GET', 'deviceManagement/roleDefinitions', [ + 'access_token' => $token, + ]); + + if ($response->failed()) { + return []; + } + + $roles = is_array($response->data['value'] ?? null) ? $response->data['value'] : []; + + $results = []; + + foreach ($roles as $role) { + $id = is_string($role['id'] ?? null) ? (string) $role['id'] : null; + $displayName = is_string($role['displayName'] ?? null) ? (string) $role['displayName'] : null; + + if (! $id || ! $displayName) { + continue; + } + + if (! str_contains(mb_strtolower($displayName), $needle)) { + continue; + } + + $results[$id] = static::formatRoleLabel($displayName, $id); + } + + ksort($results); + + return array_slice($results, 0, 20, true); + } + /** * @return array */ @@ -1314,7 +1365,11 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act public static function groupSearchHelper(?Tenant $tenant): ?string { - return null; + if (! $tenant) { + return null; + } + + return static::delegatedToken($tenant) === null ? 'Login to search groups' : null; } /** @@ -1326,6 +1381,42 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra return []; } + $token = static::delegatedToken($tenant); + + if ($token !== null) { + /** @var GraphClientInterface $graph */ + $graph = app(GraphClientInterface::class); + + $response = $graph->request('GET', 'groups', [ + 'access_token' => $token, + 'query' => [ + '$filter' => sprintf("startswith(displayName,'%s') and securityEnabled eq true", addslashes($search)), + ], + ]); + + if ($response->failed()) { + return []; + } + + $groups = is_array($response->data['value'] ?? null) ? $response->data['value'] : []; + $results = []; + + foreach ($groups as $group) { + $id = is_string($group['id'] ?? null) ? (string) $group['id'] : null; + $displayName = is_string($group['displayName'] ?? null) ? (string) $group['displayName'] : null; + + if (! $id || ! $displayName) { + continue; + } + + $results[$id] = EntraGroupLabelResolver::formatLabel($displayName, $id); + } + + ksort($results); + + return array_slice($results, 0, 20, true); + } + $needle = mb_strtolower($search); return EntraGroup::query() diff --git a/app/Jobs/SyncPoliciesJob.php b/app/Jobs/SyncPoliciesJob.php index af5668d..bac0027 100644 --- a/app/Jobs/SyncPoliciesJob.php +++ b/app/Jobs/SyncPoliciesJob.php @@ -46,7 +46,7 @@ public function handle(PolicySyncService $service, OperationRunService $operatio { $graph = app(GraphClientInterface::class); - if (! config('graph.enabled') || $graph instanceof NullGraphClient) { + if ($graph instanceof NullGraphClient) { if ($this->operationRun) { $operationRunService->updateRun( $this->operationRun, diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index badb4be..d7e07e7 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -5,8 +5,11 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\NoAccess; use App\Filament\Pages\TenantRequiredPermissions; +use App\Filament\Resources\InventoryItemResource; +use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\TenantResource; use App\Filament\Resources\Workspaces\WorkspaceResource; @@ -37,6 +40,7 @@ class AdminPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { $panel = $panel + ->default() ->id('admin') ->path('admin') ->login(Login::class) @@ -44,8 +48,6 @@ public function panel(Panel $panel): Panel ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); - - WorkspaceResource::registerRoutes($panel); }) ->colors([ 'primary' => Color::Amber, @@ -104,9 +106,15 @@ public function panel(Panel $panel): Panel ) ->resources([ TenantResource::class, + PolicyResource::class, ProviderConnectionResource::class, + InventoryItemResource::class, + WorkspaceResource::class, ]) + ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->pages([ + InventoryCoverage::class, TenantRequiredPermissions::class, ]) ->widgets([ diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 3cf942e..49e5166 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -30,7 +30,6 @@ class TenantPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { $panel = $panel - ->default() ->id('tenant') ->path('admin/t') ->login(Login::class) diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index d98d670..3a7c080 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -3,6 +3,7 @@ namespace App\Services\Inventory; use App\Models\InventoryItem; +use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; @@ -10,10 +11,14 @@ use App\Services\Graph\GraphResponse; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\Providers\ProviderReasonCodes; use Illuminate\Contracts\Cache\Lock; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use RuntimeException; use Throwable; @@ -28,6 +33,88 @@ public function __construct( private readonly ProviderGateway $providerGateway, ) {} + /** + * Runs an inventory sync immediately and persists a corresponding InventorySyncRun. + * + * This is primarily used in tests and for synchronous workflows. + * + * @param array $selectionPayload + */ + public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun + { + $computed = $this->normalizeAndHashSelection($selectionPayload); + $normalizedSelection = $computed['selection']; + $selectionHash = $computed['selection_hash']; + + $operationRun = OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()), + 'context' => $normalizedSelection, + 'started_at' => now(), + ]); + + $run = InventorySyncRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'operation_run_id' => (int) $operationRun->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $normalizedSelection, + 'status' => InventorySyncRun::STATUS_RUNNING, + 'had_errors' => false, + 'started_at' => now(), + ]); + + $result = $this->executeSelection($operationRun, $tenant, $normalizedSelection); + + $status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED); + $hadErrors = (bool) ($result['had_errors'] ?? true); + $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null; + $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null; + + $run->update([ + 'status' => $status, + 'had_errors' => $hadErrors, + 'error_codes' => $errorCodes, + 'error_context' => $errorContext, + 'items_observed_count' => (int) ($result['items_observed_count'] ?? 0), + 'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0), + 'errors_count' => (int) ($result['errors_count'] ?? 0), + 'finished_at' => now(), + ]); + + $policyTypes = $normalizedSelection['policy_types'] ?? []; + $policyTypes = is_array($policyTypes) ? $policyTypes : []; + + $operationOutcome = match ($status) { + 'success' => OperationRunOutcome::Succeeded->value, + 'partial' => OperationRunOutcome::PartiallySucceeded->value, + 'skipped' => OperationRunOutcome::Blocked->value, + default => OperationRunOutcome::Failed->value, + }; + + $operationRun->update([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => $operationOutcome, + 'summary_counts' => [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)), + 'failed' => (int) ($result['errors_count'] ?? 0), + 'items' => (int) ($result['items_observed_count'] ?? 0), + 'updated' => (int) ($result['items_upserted_count'] ?? 0), + ], + 'completed_at' => now(), + ]); + + return $run->refresh(); + } + /** * Runs an inventory sync (inline), enforcing locks/concurrency. * diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 0cfe5f3..a8d04f3 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -65,7 +65,14 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + $tenantParameter = null; if ($request->route()?->hasParameter('tenant')) { + $tenantParameter = $request->route()->parameter('tenant'); + } elseif (filled($request->query('tenant'))) { + $tenantParameter = $request->query('tenant'); + } + + if ($tenantParameter !== null) { $user = $request->user(); if ($user === null) { @@ -76,12 +83,9 @@ public function handle(Request $request, Closure $next): Response abort(404); } - if (! $panel->hasTenancy()) { - return $next($request); - } - - $tenantParameter = $request->route()->parameter('tenant'); - $tenant = $panel->getTenant($tenantParameter); + $tenant = $tenantParameter instanceof Tenant + ? $tenantParameter + : Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first(); if (! $tenant instanceof Tenant) { abort(404); diff --git a/app/Support/Operations/OperationRunCapabilityResolver.php b/app/Support/Operations/OperationRunCapabilityResolver.php index 2722737..b725faf 100644 --- a/app/Support/Operations/OperationRunCapabilityResolver.php +++ b/app/Support/Operations/OperationRunCapabilityResolver.php @@ -21,7 +21,9 @@ public function requiredCapabilityForType(string $operationType): ?string 'restore.execute' => Capabilities::TENANT_MANAGE, 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, - 'provider.connection.check' => Capabilities::PROVIDER_RUN, + // Viewing verification reports should be possible for readonly members. + // Starting verification is separately guarded by the verification service. + 'provider.connection.check' => Capabilities::PROVIDER_VIEW, // Keep legacy / unknown types viewable by membership+entitlement only. default => null, diff --git a/phpunit.xml b/phpunit.xml index fb74b6b..dd69fbd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,7 @@ - + diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php index a2f0b5a..8e92051 100644 --- a/resources/views/filament/partials/context-bar.blade.php +++ b/resources/views/filament/partials/context-bar.blade.php @@ -87,7 +87,7 @@ Switch workspace diff --git a/routes/web.php b/routes/web.php index 09ee403..71a2d1e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,7 +27,7 @@ Route::get('/admin/consent/start', TenantOnboardingController::class) ->name('admin.consent.start'); -// Panel root override: keep the app's workspace-first flow. + // Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows. // when no default tenant can be resolved. Route::middleware([ @@ -148,6 +148,19 @@ ->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class) ->name('admin.operations.index'); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/t/{tenant:external_id}/operations', fn () => redirect()->route('admin.operations.index')) + ->name('admin.operations.legacy-tenant-index'); + Route::middleware([ 'web', 'panel:admin', diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index 74b990b..9caa638 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -16,6 +16,8 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); + $policies = Policy::factory() ->count(3) ->create([ diff --git a/tests/Feature/DependencyExtractionFeatureTest.php b/tests/Feature/DependencyExtractionFeatureTest.php index d1d5236..be514ea 100644 --- a/tests/Feature/DependencyExtractionFeatureTest.php +++ b/tests/Feature/DependencyExtractionFeatureTest.php @@ -53,6 +53,7 @@ public function request(string $method, string $path, array $options = []): Grap it('extracts edges during inventory sync and marks missing appropriately', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps); $svc = app(InventorySyncService::class); @@ -73,6 +74,7 @@ public function request(string $method, string $path, array $options = []): Grap it('respects 50-edge limit for outbound extraction', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); // Fake client returning 60 group assignments $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface @@ -132,6 +134,7 @@ public function request(string $method, string $path, array $options = []): Grap it('persists unsupported reference warnings on the sync run record', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface @@ -276,6 +279,7 @@ public function request(string $method, string $path, array $options = []): Grap it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface diff --git a/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php index c24b180..b364493 100644 --- a/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php +++ b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php @@ -97,6 +97,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-1', ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::factory()->create([ 'tenant_id' => $tenant->id, 'external_id' => 'dcs-1', diff --git a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php index c19edbc..b5e32bd 100644 --- a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php +++ b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php @@ -58,6 +58,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/EndpointSecurityPolicyRestore023Test.php b/tests/Feature/EndpointSecurityPolicyRestore023Test.php index 1a4821e..056976d 100644 --- a/tests/Feature/EndpointSecurityPolicyRestore023Test.php +++ b/tests/Feature/EndpointSecurityPolicyRestore023Test.php @@ -82,6 +82,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, @@ -163,6 +165,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php index 1294a72..836b8f7 100644 --- a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php +++ b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php @@ -57,6 +57,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'ca-policy-1', diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php index 64703da..4099ec3 100644 --- a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -57,6 +57,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'enrollment-restriction-1', @@ -159,6 +161,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'enrollment-limit-1', diff --git a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php index dce8d9c..869b9f9 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php @@ -95,6 +95,8 @@ public function request(string $method, string $path, array $options = []): Grap 'status' => 'active', ]); + ensureDefaultProviderConnection($tenant); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; diff --git a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php index c46565d..d19a3e2 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php @@ -103,6 +103,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'gpo-1', diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 0f9b627..6a3ed26 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -54,6 +54,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'status' => 'active', ]); + ensureDefaultProviderConnection($tenant); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 236a37d..6f91f4d 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -36,7 +36,7 @@ ]); $this->actingAs($user) - ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))) + ->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee('Policy A') ->assertDontSee('Policy B'); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php index 010e84c..b074307 100644 --- a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -44,7 +44,7 @@ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings&tenant='.(string) $tenant->external_id); $response->assertOk(); $response->assertSee('Settings'); diff --git a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php index 875f1dd..e0aacb8 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php @@ -71,6 +71,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'gpo-versioned-1', diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php index 915d41d..0cd73b8 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -1,7 +1,6 @@ callTableAction('restore_via_wizard', $version) - ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false, tenant: $tenant)); + ->assertRedirectContains('/admin/restore-runs/create') + ->assertRedirectContains('tenant='.(string) $tenant->external_id); $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); expect($backupSet)->not->toBeNull(); diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index f8cff84..2b1c11e 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -126,7 +126,7 @@ ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings'); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id); $response->assertOk(); $response->assertSee('Enrollment notifications'); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index af6bf77..2304e38 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -56,6 +56,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant One', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -148,6 +149,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ]); $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create(); $backupItem = BackupItem::factory() ->for($tenant) @@ -251,6 +253,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant Three', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -365,6 +368,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'status' => 'active', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -481,6 +485,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant One', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', @@ -586,6 +591,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant Four', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', diff --git a/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php b/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php index 5fa4058..70acfa0 100644 --- a/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php +++ b/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php @@ -26,7 +26,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName bindFailHardGraphClient(); $this->actingAs($user) - ->get(RestoreRunResource::getUrl('create', tenant: $tenant)) + ->get(RestoreRunResource::getUrl('create').'?tenant='.(string) $tenant->external_id) ->assertOk() ->assertSee('Create restore run') ->assertSee('Select Backup Set'); @@ -54,7 +54,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName bindFailHardGraphClient(); - $url = RestoreRunResource::getUrl('create', tenant: $tenant).'?backup_set_id='.$backupSet->getKey(); + $url = RestoreRunResource::getUrl('create').'?backup_set_id='.$backupSet->getKey().'&tenant='.(string) $tenant->external_id; $this->actingAs($user) ->get($url) diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index f6db31f..5485b52 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -50,10 +50,10 @@ ], ]); - $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index', tenant: $tenant)) + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index').'?tenant='.(string) $tenant->external_id) ->assertSuccessful(); - $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings') + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id) ->assertSuccessful(); $originalEnv !== false @@ -116,9 +116,9 @@ ], ]); - $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]).'?tenant='.(string) $tenant->external_id; - $this->get($url.'?tab=diff') + $this->get($url.'&tab=diff') ->assertSuccessful() ->assertSeeText('Fullscreen') ->assertSeeText("- Write-Host 'one'") @@ -181,9 +181,9 @@ ], ]); - $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]).'?tenant='.(string) $tenant->external_id; - $this->get($url.'?tab=diff') + $this->get($url.'&tab=diff') ->assertSuccessful() ->assertSeeText('Fullscreen') ->assertSeeText("- Write-Host 'one'") diff --git a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php index e4ad978..f32ce88 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php @@ -73,6 +73,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; @@ -126,6 +128,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index 67d75e6..f447cef 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -2,7 +2,10 @@ use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; +use App\Models\Workspace; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\PolicySyncService; @@ -82,6 +85,27 @@ public function request(string $method, string $path, array $options = []): Grap $tenant->makeCurrent(); expect(Tenant::current()->id)->toBe($tenant->id); + $workspace = Workspace::factory()->create(); + $tenant->forceFill(['workspace_id' => (int) $workspace->getKey()])->save(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenant->tenant_id, + 'is_default' => true, + 'status' => 'connected', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + ], + ]); + app(PolicySyncService::class)->syncPolicies($tenant); $settingsPolicy = Policy::where('policy_type', 'settingsCatalogPolicy')->first(); @@ -113,7 +137,7 @@ public function request(string $method, string $path, array $options = []): Grap $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))); + ->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($tenant))); $response->assertOk(); $response->assertSee('Settings Catalog Policy'); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index 5037867..efa63cd 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -103,6 +103,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-3', diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index df8b4dd..371003b 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -3,6 +3,8 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; @@ -12,6 +14,30 @@ uses(RefreshDatabase::class); +if (! function_exists('makeTenantWithDefaultProviderConnection')) { + function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant + { + $tenant = Tenant::create(array_merge([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ], $attributes)); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->id, + ]); + + return $tenant; + } +} + class SettingsCatalogRestoreGraphClient implements GraphClientInterface { /** @@ -105,11 +131,7 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -204,7 +226,7 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance'); $response = $this - ->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); + ->get(route('filament.tenant.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response->assertOk(); $response->assertSee('settings are read-only'); @@ -225,10 +247,9 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = makeTenantWithDefaultProviderConnection([ 'tenant_id' => 'tenant-2', 'name' => 'Tenant Two', - 'metadata' => [], ]); $policy = Policy::create([ @@ -346,10 +367,9 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = makeTenantWithDefaultProviderConnection([ 'tenant_id' => 'tenant-4', 'name' => 'Tenant Four', - 'metadata' => [], ]); $policy = Policy::create([ @@ -464,10 +484,9 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = makeTenantWithDefaultProviderConnection([ 'tenant_id' => 'tenant-5', 'name' => 'Tenant Five', - 'metadata' => [], ]); $policy = Policy::create([ diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index 034ea0f..6a02b5c 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -56,8 +56,10 @@ [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + $tenant->makeCurrent(); + $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy], panel: 'admin').'?tab=settings&tenant='.(string) $tenant->external_id); $policyResponse->assertOk(); $policyResponse->assertSee('fi-width-full'); @@ -68,7 +70,7 @@ $policyResponse->assertSee('fi-ta-table'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], panel: 'admin').'?tenant='.(string) $tenant->external_id); $versionResponse->assertOk(); $versionResponse->assertSee('fi-width-full'); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index 29ba4df..f5b5029 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -22,7 +22,7 @@ $unauthorizedTenant = Tenant::factory()->create(); $this->actingAs($user) - ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant))) + ->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant))) ->assertNotFound(); }); diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php index ce26f41..479184e 100644 --- a/tests/Feature/Filament/TenantRbacWizardTest.php +++ b/tests/Feature/Filament/TenantRbacWizardTest.php @@ -2,6 +2,8 @@ use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; @@ -19,13 +21,30 @@ function tenantWithApp(): Tenant { - return Tenant::create([ + $tenant = Tenant::create([ 'tenant_id' => 'tenant-guid', 'name' => 'Tenant One', 'app_client_id' => 'client-123', 'app_client_secret' => 'secret', 'status' => 'active', ]); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-123', + 'client_secret' => 'secret', + ], + ]); + + return $tenant; } test('rbac action prompts login when no delegated token', function () { diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index 058c034..ed5d69e 100644 --- a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -4,6 +4,8 @@ use App\Models\BackupSet; use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; @@ -13,6 +15,30 @@ uses(RefreshDatabase::class); +if (! function_exists('makeTenantWithDefaultProviderConnection')) { + function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant + { + $tenant = Tenant::create(array_merge([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ], $attributes)); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->id, + ]); + + return $tenant; + } +} + class WindowsUpdateProfilesRestoreGraphClient implements GraphClientInterface { /** @@ -62,11 +88,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon $client = new WindowsUpdateProfilesRestoreGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -141,11 +163,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon $client = new WindowsUpdateProfilesRestoreGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -220,11 +238,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon $client = new WindowsUpdateProfilesRestoreGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php index a69832f..5b75d6b 100644 --- a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -4,6 +4,8 @@ use App\Models\BackupSet; use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; @@ -13,6 +15,30 @@ uses(RefreshDatabase::class); +if (! function_exists('makeTenantWithDefaultProviderConnection')) { + function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant + { + $tenant = Tenant::create(array_merge([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ], $attributes)); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->id, + ]); + + return $tenant; + } +} + test('restore execution applies windows update ring and records audit log', function () { $client = new class implements GraphClientInterface { @@ -66,11 +92,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/InventoryItemDependenciesTest.php b/tests/Feature/InventoryItemDependenciesTest.php index 8ba5d22..e010de2 100644 --- a/tests/Feature/InventoryItemDependenciesTest.php +++ b/tests/Feature/InventoryItemDependenciesTest.php @@ -18,7 +18,7 @@ ]); // Zero state - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url)->assertOk()->assertSee('No dependencies found'); // Create a missing edge and assert badge appears @@ -71,10 +71,10 @@ 'relationship_type' => 'depends_on', ]); - $urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=outbound'; + $urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; $this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found'); - $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound'; + $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound'; $this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found'); }); @@ -109,8 +109,8 @@ 'metadata' => ['last_known_name' => 'Scoped Target'], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant) - .'?direction=outbound&relationship_type=scoped_by'; + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin') + .'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by'; $this->get($url) ->assertOk() @@ -141,7 +141,7 @@ 'metadata' => ['last_known_name' => 'Other Tenant Edge'], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertDontSee('Other Tenant Edge'); @@ -170,7 +170,7 @@ ], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertSee('Group (external): 123456…'); @@ -239,7 +239,7 @@ ], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertSee('Scope Tag: Finance (6…)') @@ -286,7 +286,7 @@ ], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertSee('Scope Tag: Finance'); @@ -301,6 +301,6 @@ 'external_id' => (string) Str::uuid(), ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url)->assertRedirect(); }); diff --git a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php index fb3dcb1..974302f 100644 --- a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php +++ b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php @@ -55,6 +55,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'config-1', diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php index 4ae8c1f..b21c9c7 100644 --- a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -55,6 +55,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + // Create an ignored policy $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -100,6 +102,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + // Create multiple ignored policies Policy::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php b/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php index 0e4fd5d..e41e5d4 100644 --- a/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php +++ b/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\TenantResource; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -12,7 +13,7 @@ [$user] = createUserWithTenant($tenant, role: 'readonly'); $this->actingAs($user) - ->get("/admin/t/{$tenant->external_id}/tenants/{$tenant->id}/edit") + ->get(TenantResource::getUrl('edit', ['record' => $tenant])) ->assertForbidden(); }); diff --git a/tests/Feature/Monitoring/HeaderContextBarTest.php b/tests/Feature/Monitoring/HeaderContextBarTest.php index 30c4d9e..99c1624 100644 --- a/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -25,7 +25,6 @@ ->get('/admin/operations') ->assertOk() ->assertSee($workspaceName ?? 'Select workspace') - ->assertSee('Select tenant') ->assertSee('Search tenants…') ->assertSee('Switch workspace') ->assertSee('admin/select-tenant') diff --git a/tests/Feature/OpsUx/FailureSanitizationTest.php b/tests/Feature/OpsUx/FailureSanitizationTest.php index 010450d..cc6afce 100644 --- a/tests/Feature/OpsUx/FailureSanitizationTest.php +++ b/tests/Feature/OpsUx/FailureSanitizationTest.php @@ -38,7 +38,7 @@ expect($failureSummaryJson)->not->toContain($rawBearer); expect($failureSummaryJson)->not->toContain('test.user@example.com'); - expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('permission_denied'); + expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('provider_permission_denied'); $notification = DatabaseNotification::query() ->where('notifiable_id', $user->getKey()) diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php index a21e3b3..ab5ca32 100644 --- a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -19,6 +19,7 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); // Simulate an older bug: ESP row was synced under enrollmentRestriction. $wrong = Policy::create([ @@ -81,6 +82,7 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); $this->mock(GraphClientInterface::class, function (MockInterface $mock) { $payload = [ @@ -172,6 +174,7 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); $this->mock(GraphClientInterface::class, function (MockInterface $mock) { $limitPayload = [ diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index 0e248d2..e34739c 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -212,8 +212,8 @@ $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( filamentTenantRouteParams($this->tenant), - ['record' => $version], - )).'?tab=normalized-settings'); + ['record' => $version, 'tab' => 'normalized-settings'], + ))); $response->assertOk(); $response->assertSee('Password & Access'); diff --git a/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php b/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php index a97a882..7a236d3 100644 --- a/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php @@ -4,10 +4,12 @@ use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\ProviderConnection; use App\Models\ProviderCredential; use App\Models\Tenant; +use App\Models\Workspace; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Graph\ScopeTagResolver; @@ -16,6 +18,7 @@ use App\Services\Intune\RbacHealthService; use App\Services\Intune\RestoreService; use App\Services\Inventory\InventorySyncService; +use App\Support\OperationRunType; use App\Support\RbacReason; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -26,11 +29,14 @@ */ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ 'tenant_id' => $tenantId, 'status' => 'active', 'app_client_id' => null, 'app_client_secret' => null, + 'workspace_id' => (int) $workspace->getKey(), ]); $connection = ProviderConnection::factory()->create([ @@ -79,17 +85,23 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array app()->instance(GraphClientInterface::class, $graph); - $run = app(InventorySyncService::class)->syncNow( - $setup['tenant'], - [ - 'policy_types' => ['deviceConfiguration'], - 'categories' => ['Configuration'], - 'include_foundations' => false, - 'include_dependencies' => false, - ], - ); + $service = app(InventorySyncService::class); + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; - expect($run->status)->toBe('success'); + $opRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $setup['tenant']->getKey(), + 'workspace_id' => (int) $setup['tenant']->workspace_id, + 'type' => OperationRunType::InventorySync->value, + ]); + + $result = $service->executeSelection($opRun, $setup['tenant'], $selection); + + expect($result['status'])->toBe('success'); }); it('Spec081 smoke: policy sync uses provider connection credentials with tenant secrets empty', function (): void { diff --git a/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php b/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php index f0aa71b..4828fec 100644 --- a/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php +++ b/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; -use App\Jobs\ProviderConnectionHealthCheckJob; +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\TenantOnboardingSession; @@ -101,6 +101,14 @@ ->test(ManagedTenantOnboardingWizard::class) ->call('startVerification'); - Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + + Queue::assertNothingPushed(); }); }); diff --git a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php index 9a1871f..f6e1690 100644 --- a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php +++ b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php @@ -1,6 +1,7 @@ actingAs($user); $tenant->makeCurrent(); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + expect(RegisterTenant::canView())->toBeFalse(); + + Filament::setCurrentPanel(null); }); it('is visible for owner members', function () { @@ -18,6 +23,10 @@ $this->actingAs($user); $tenant->makeCurrent(); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + expect(RegisterTenant::canView())->toBeTrue(); + + Filament::setCurrentPanel(null); }); }); diff --git a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php index 281fdbc..94bb9f1 100644 --- a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php +++ b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php @@ -2,6 +2,7 @@ use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Resources\TenantResource\Pages\CreateTenant; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -12,11 +13,15 @@ $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + expect(RegisterTenant::canView())->toBeFalse(); Livewire::actingAs($user) ->test(RegisterTenant::class) ->assertStatus(404); + + Filament::setCurrentPanel(null); }); test('readonly users cannot create tenants', function () { diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php index 501db21..8325536 100644 --- a/tests/Feature/RestoreAssignmentApplicationTest.php +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -81,6 +81,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-1', @@ -185,6 +187,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-1', @@ -271,6 +275,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-1', diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php index 62724de..57649b3 100644 --- a/tests/Feature/RestoreGraphErrorMetadataTest.php +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -65,6 +65,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/RestoreScopeTagMappingTest.php b/tests/Feature/RestoreScopeTagMappingTest.php index 9eab0e3..cfd95a4 100644 --- a/tests/Feature/RestoreScopeTagMappingTest.php +++ b/tests/Feature/RestoreScopeTagMappingTest.php @@ -63,6 +63,8 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); + $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 2, diff --git a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php index 43f6742..2445021 100644 --- a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php +++ b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php @@ -82,6 +82,7 @@ public function request(string $method, string $path, array $options = []): Grap config()->set('graph_contracts.types.securityBaselinePolicy', []); $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php index c8d216d..d903e4a 100644 --- a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php +++ b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php @@ -5,6 +5,7 @@ use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -19,6 +20,8 @@ $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + $tenantGuid = '11111111-1111-1111-1111-111111111111'; Livewire::test(RegisterTenant::class) @@ -28,6 +31,8 @@ ->set('data.domain', 'acme.example') ->call('register'); + Filament::setCurrentPanel(null); + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); $membership = TenantMembership::query() diff --git a/tests/Feature/TermsAndConditionsPolicyTypeTest.php b/tests/Feature/TermsAndConditionsPolicyTypeTest.php index 84cce93..77c7909 100644 --- a/tests/Feature/TermsAndConditionsPolicyTypeTest.php +++ b/tests/Feature/TermsAndConditionsPolicyTypeTest.php @@ -95,6 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-1']); + ensureDefaultProviderConnection($tenant); $policy = Policy::factory()->create([ 'tenant_id' => $tenant->id, 'external_id' => 'tc-1', @@ -182,6 +183,7 @@ public function request(string $method, string $path, array $options = []): Grap it('syncs terms and conditions from graph', function () { $tenant = Tenant::factory()->create(['status' => 'active']); + ensureDefaultProviderConnection($tenant); $logger = mock(GraphLogger::class); $logger->shouldReceive('logRequest') diff --git a/tests/Pest.php b/tests/Pest.php index e1c324c..4117f08 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ (string) $tenant->external_id]; } + +function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection +{ + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', $provider) + ->where('is_default', true) + ->orderBy('id') + ->first(); + + if (! $connection instanceof ProviderConnection) { + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => $provider, + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? fake()->uuid()), + 'status' => 'connected', + 'health_status' => 'ok', + 'is_default' => true, + ]); + } + + $credential = $connection->credential()->first(); + + if (! $credential instanceof ProviderCredential) { + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => fake()->uuid(), + 'client_secret' => fake()->sha1(), + ], + ]); + + $connection->refresh(); + } + + return $connection; +} diff --git a/tests/Unit/Auth/NoRoleStringChecksTest.php b/tests/Unit/Auth/NoRoleStringChecksTest.php index 3afca09..ce55b0b 100644 --- a/tests/Unit/Auth/NoRoleStringChecksTest.php +++ b/tests/Unit/Auth/NoRoleStringChecksTest.php @@ -17,16 +17,16 @@ $patterns = [ // $membership->role === 'owner' / !== 'owner' - '/->role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i', + '/->role\s*(===|==|!==|!=)\s*(["\'])'.$roleValuePattern.'\2/i', // $role === 'owner' - '/\$role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i', + '/\$role\s*(===|==|!==|!=)\s*(["\'])'.$roleValuePattern.'\2/i', // case 'owner': - '/\bcase\s*[\"\']?'.$roleValuePattern.'[\"\']?\s*:/i', + '/\bcase\s*(["\'])'.$roleValuePattern.'\1\s*:/i', // match (...) { 'owner' => ... } - '/\bmatch\b[\s\S]*?\{[\s\S]*?[\"\']?'.$roleValuePattern.'[\"\']?\s*=>/i', + '/\bmatch\b[\s\S]*?\{[\s\S]*?(["\'])'.$roleValuePattern.'\1\s*=>/i', ]; $filesystem = new Filesystem;