diff --git a/tests/Feature/Audit/AssignmentRestoreAuditSummaryTest.php b/tests/Feature/Audit/AssignmentRestoreAuditSummaryTest.php new file mode 100644 index 0000000..3d2962e --- /dev/null +++ b/tests/Feature/Audit/AssignmentRestoreAuditSummaryTest.php @@ -0,0 +1,131 @@ +instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-assignment-audit-summary', + ]); + ensureDefaultProviderConnection($tenant); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-assignment-audit-summary', + 'policy_type' => 'settingsCatalogPolicy', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-source-1', + ], + ], + [ + 'id' => 'assignment-2', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-source-2', + ], + ], + ], + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'assignment.audit.summary@example.com', + ]); + $this->actingAs($user); + + $restoreRun = app(RestoreService::class)->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [(int) $backupItem->getKey()], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'group-source-1' => 'group-target-1', + 'group-source-2' => 'group-target-2', + ], + ); + + $summaryEntries = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'restore.assignments.summary') + ->where('resource_type', 'restore_run') + ->where('resource_id', (string) $restoreRun->getKey()) + ->get(); + + expect($summaryEntries)->toHaveCount(1); + expect($summaryEntries->first()?->metadata['succeeded'] ?? null)->toBe(2); + expect($summaryEntries->first()?->metadata['failed'] ?? null)->toBe(0); + + $perAssignmentEntryCount = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('action', [ + 'restore.assignment.created', + 'restore.assignment.failed', + 'restore.assignment.skipped', + ]) + ->count(); + + expect($perAssignmentEntryCount)->toBe(0); +}); diff --git a/tests/Feature/Graph/AssignmentGraphServiceResolutionTest.php b/tests/Feature/Graph/AssignmentGraphServiceResolutionTest.php new file mode 100644 index 0000000..b6366e5 --- /dev/null +++ b/tests/Feature/Graph/AssignmentGraphServiceResolutionTest.php @@ -0,0 +1,61 @@ +instance(GraphClientInterface::class, $fake); + + $fetcher = app(AssignmentFetcher::class); + $groupResolver = app(GroupResolver::class); + $filterResolver = app(AssignmentFilterResolver::class); + + $fetcherProperty = new \ReflectionProperty(AssignmentFetcher::class, 'graphClient'); + $fetcherProperty->setAccessible(true); + $groupResolverProperty = new \ReflectionProperty(GroupResolver::class, 'graphClient'); + $groupResolverProperty->setAccessible(true); + $filterResolverProperty = new \ReflectionProperty(AssignmentFilterResolver::class, 'graphClient'); + $filterResolverProperty->setAccessible(true); + + expect($fetcherProperty->getValue($fetcher))->toBe($fake); + expect($groupResolverProperty->getValue($groupResolver))->toBe($fake); + expect($filterResolverProperty->getValue($filterResolver))->toBe($fake); +}); diff --git a/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php b/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php new file mode 100644 index 0000000..c4ae48f --- /dev/null +++ b/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php @@ -0,0 +1,21 @@ +create(); + $workspace = $tenant->workspace; + + expect($workspace)->not->toBeNull(); + + $platformUser = PlatformUser::factory()->create(); + + $workspaceRouteKey = (string) ($workspace->slug ?? $workspace->getKey()); + + $this->actingAs($platformUser, 'platform') + ->get("/admin/w/{$workspaceRouteKey}/ping") + ->assertNotFound(); +}); diff --git a/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php b/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php new file mode 100644 index 0000000..e118cb7 --- /dev/null +++ b/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php @@ -0,0 +1,39 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'restore.execute', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + Bus::fake(); + Filament::setTenant(null, true); + + assertNoOutboundHttp(function () use ($tenant, $run): void { + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('All'); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk() + ->assertSee('Operation run'); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php index 7a69368..76840c1 100644 --- a/tests/Feature/OperationRunServiceTest.php +++ b/tests/Feature/OperationRunServiceTest.php @@ -36,6 +36,77 @@ expect(OperationRun::query()->count())->toBe(1); }); +it('dedupes assignment run identities by type and scope', function () { + $tenant = Tenant::factory()->create(); + + $service = new OperationRunService; + + $fetchRunA = $service->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.fetch', + identityInputs: [ + 'backup_item_id' => 101, + ], + context: [ + 'backup_item_id' => 101, + 'phase' => 'capture', + ], + ); + + $fetchRunB = $service->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.fetch', + identityInputs: [ + 'backup_item_id' => 101, + ], + context: [ + 'backup_item_id' => 101, + 'phase' => 'capture-again', + ], + ); + + $fetchRunDifferentScope = $service->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.fetch', + identityInputs: [ + 'backup_item_id' => 102, + ], + context: [ + 'backup_item_id' => 102, + 'phase' => 'capture', + ], + ); + + $restoreRunA = $service->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.restore', + identityInputs: [ + 'restore_run_id' => 501, + ], + context: [ + 'restore_run_id' => 501, + 'phase' => 'execute', + ], + ); + + $restoreRunB = $service->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.restore', + identityInputs: [ + 'restore_run_id' => 501, + ], + context: [ + 'restore_run_id' => 501, + 'phase' => 'execute-again', + ], + ); + + expect($fetchRunA->getKey())->toBe($fetchRunB->getKey()); + expect($restoreRunA->getKey())->toBe($restoreRunB->getKey()); + expect($fetchRunA->getKey())->not->toBe($fetchRunDifferentScope->getKey()); + expect($fetchRunA->getKey())->not->toBe($restoreRunA->getKey()); +}); + it('does not replace the initiator when deduping', function () { $tenant = Tenant::factory()->create(); $userA = User::factory()->create(); diff --git a/tests/Feature/Operations/AssignmentFetchOperationRunFailureTest.php b/tests/Feature/Operations/AssignmentFetchOperationRunFailureTest.php new file mode 100644 index 0000000..51677e0 --- /dev/null +++ b/tests/Feature/Operations/AssignmentFetchOperationRunFailureTest.php @@ -0,0 +1,129 @@ +instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse( + success: false, + data: [], + status: 400, + errors: [ + ['code' => 'BadRequest', 'message' => 'Bad request'], + ], + warnings: [], + meta: [ + 'error_code' => 'BadRequest', + 'error_message' => 'Assignment list request failed', + ], + ); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-assignment-fetch-failure', + 'status' => 'active', + ]); + ensureDefaultProviderConnection($tenant); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-assignment-fetch-failure', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + ]); + + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy): void { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'id' => (string) $policy->external_id, + 'name' => 'Policy snapshot', + 'roleScopeTagIds' => ['0'], + ], + 'metadata' => [], + 'warnings' => [], + ]); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant): void { + $mock->shouldReceive('resolve') + ->once() + ->with(['0'], $tenant) + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + $backupSet = app(BackupService::class)->createBackupSet( + tenant: $tenant, + policyIds: [(int) $policy->getKey()], + actorEmail: 'assignment.fetch.failure@example.com', + actorName: 'Assignment Fetch Failure', + includeAssignments: true, + includeScopeTags: true, + ); + + $backupItem = $backupSet->items()->first(); + expect($backupItem)->not->toBeNull(); + expect($backupItem?->metadata['assignments_fetch_failed'] ?? false)->toBeTrue(); + + $assignmentFetchRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'assignments.fetch') + ->latest('id') + ->first(); + + expect($assignmentFetchRun)->not->toBeNull(); + expect($assignmentFetchRun?->status)->toBe('completed'); + expect($assignmentFetchRun?->outcome)->toBe('failed'); + expect($assignmentFetchRun?->summary_counts ?? [])->toMatchArray([ + 'total' => 1, + 'processed' => 0, + 'failed' => 1, + ]); + expect($assignmentFetchRun?->failure_summary[0]['code'] ?? null)->toBe('assignments.fetch_failed'); + expect(ProviderReasonCodes::isKnown((string) ($assignmentFetchRun?->failure_summary[0]['reason_code'] ?? '')))->toBeTrue(); +}); diff --git a/tests/Feature/Operations/AssignmentFetchOperationRunTest.php b/tests/Feature/Operations/AssignmentFetchOperationRunTest.php new file mode 100644 index 0000000..b9082c9 --- /dev/null +++ b/tests/Feature/Operations/AssignmentFetchOperationRunTest.php @@ -0,0 +1,114 @@ +create([ + 'tenant_id' => 'tenant-assignment-fetch-run', + 'status' => 'active', + ]); + ensureDefaultProviderConnection($tenant); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-assignment-fetch-run', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + ]); + + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy): void { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'id' => (string) $policy->external_id, + 'name' => 'Policy snapshot', + 'roleScopeTagIds' => ['0'], + ], + 'metadata' => [], + 'warnings' => [], + ]); + }); + + $this->mock(AssignmentFetcher::class, function (MockInterface $mock): void { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function (MockInterface $mock): void { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-1' => [ + 'id' => 'group-1', + 'displayName' => 'Group One', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function (MockInterface $mock): void { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([]); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant): void { + $mock->shouldReceive('resolve') + ->once() + ->with(['0'], $tenant) + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + $backupSet = app(BackupService::class)->createBackupSet( + tenant: $tenant, + policyIds: [(int) $policy->getKey()], + actorEmail: 'assignment.fetch.run@example.com', + actorName: 'Assignment Fetch', + includeAssignments: true, + includeScopeTags: true, + ); + + $backupItem = $backupSet->items()->first(); + expect($backupItem)->not->toBeNull(); + + $assignmentFetchRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'assignments.fetch') + ->latest('id') + ->first(); + + expect($assignmentFetchRun)->not->toBeNull(); + expect($assignmentFetchRun?->status)->toBe('completed'); + expect($assignmentFetchRun?->outcome)->toBe('succeeded'); + expect($assignmentFetchRun?->context['backup_item_id'] ?? null)->toBe((int) $backupItem?->getKey()); + expect($assignmentFetchRun?->summary_counts ?? [])->toMatchArray([ + 'total' => 1, + 'processed' => 1, + 'failed' => 0, + ]); +}); diff --git a/tests/Feature/Operations/AssignmentRestoreOperationRunFailureTest.php b/tests/Feature/Operations/AssignmentRestoreOperationRunFailureTest.php new file mode 100644 index 0000000..eb41e66 --- /dev/null +++ b/tests/Feature/Operations/AssignmentRestoreOperationRunFailureTest.php @@ -0,0 +1,135 @@ +instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse( + success: false, + data: [], + status: 400, + errors: [ + ['code' => 'BadRequest', 'message' => 'Bad request'], + ], + warnings: [], + meta: [ + 'error_code' => 'BadRequest', + 'error_message' => 'Bad request while restoring assignments', + ], + ); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-assignment-restore-failure', + ]); + ensureDefaultProviderConnection($tenant); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-assignment-restore-failure', + 'policy_type' => 'settingsCatalogPolicy', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-source-1', + ], + ], + ], + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'assignment.restore.failure@example.com', + ]); + $this->actingAs($user); + + app(RestoreService::class)->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [(int) $backupItem->getKey()], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'group-source-1' => 'group-target-1', + ], + ); + + $assignmentRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'assignments.restore') + ->latest('id') + ->first(); + + expect($assignmentRun)->not->toBeNull(); + expect($assignmentRun?->status)->toBe('completed'); + expect($assignmentRun?->outcome)->toBe('failed'); + expect($assignmentRun?->summary_counts ?? [])->toMatchArray([ + 'total' => 1, + 'processed' => 0, + 'failed' => 1, + ]); + + $failure = $assignmentRun?->failure_summary[0] ?? []; + + expect($failure['code'] ?? null)->toBe('assignments.restore_failed'); + expect($failure['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionInvalid); + expect((string) ($failure['message'] ?? ''))->not->toBe(''); +}); diff --git a/tests/Feature/Operations/AssignmentRestoreOperationRunTest.php b/tests/Feature/Operations/AssignmentRestoreOperationRunTest.php new file mode 100644 index 0000000..c7e5327 --- /dev/null +++ b/tests/Feature/Operations/AssignmentRestoreOperationRunTest.php @@ -0,0 +1,117 @@ +instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-assignment-restore-success', + ]); + ensureDefaultProviderConnection($tenant); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-assignment-restore-success', + 'policy_type' => 'settingsCatalogPolicy', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_identifier' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-source-1', + ], + ], + ], + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'assignment.restore.success@example.com', + ]); + $this->actingAs($user); + + $restoreRun = app(RestoreService::class)->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [(int) $backupItem->getKey()], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'group-source-1' => 'group-target-1', + ], + ); + + $assignmentRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'assignments.restore') + ->latest('id') + ->first(); + + expect($assignmentRun)->not->toBeNull(); + expect($assignmentRun?->status)->toBe('completed'); + expect($assignmentRun?->outcome)->toBe('succeeded'); + expect($assignmentRun?->context['restore_run_id'] ?? null)->toBe((int) $restoreRun->getKey()); + expect($assignmentRun?->summary_counts ?? [])->toMatchArray([ + 'total' => 1, + 'processed' => 1, + 'failed' => 0, + ]); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php new file mode 100644 index 0000000..995ef60 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php @@ -0,0 +1,23 @@ +tenants()->detach((int) $tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListProviderConnections::class) + ->assertActionExists('create', function (Action $action): bool { + return $action->isAuthorized() === false; + }); +}); diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php new file mode 100644 index 0000000..92901e9 --- /dev/null +++ b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php @@ -0,0 +1,60 @@ +actingAs($owner); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + $backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + $outsider = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $outsider->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($outsider); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) + ->assertNotFound(); +}); + +it('keeps actions visible but disabled for members missing capability', function (): void { + [$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($readonlyUser); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + $backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('remove', $backupItem) + ->assertTableActionDisabled('remove', $backupItem); +}); diff --git a/tests/Unit/AssignmentRestoreServiceTest.php b/tests/Unit/AssignmentRestoreServiceTest.php index 78308ff..7f04b8a 100644 --- a/tests/Unit/AssignmentRestoreServiceTest.php +++ b/tests/Unit/AssignmentRestoreServiceTest.php @@ -1,6 +1,5 @@ graphClient = Mockery::mock(GraphClientInterface::class); - $this->auditLogger = Mockery::mock(AuditLogger::class); $this->filterResolver = Mockery::mock(AssignmentFilterResolver::class); $this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault(); @@ -39,7 +36,6 @@ $this->graphClient, app(GraphContractRegistry::class), app(GraphLogger::class), - $this->auditLogger, $this->filterResolver, app(MicrosoftGraphOptionsResolver::class), ); @@ -80,11 +76,6 @@ )) ->andReturn(new GraphResponse(success: true, data: [])); - $this->auditLogger - ->shouldReceive('log') - ->once() - ->andReturn(new AuditLog); - $result = $this->service->restore( $tenant, 'deviceManagementScript', @@ -125,11 +116,6 @@ )) ->andReturn(new GraphResponse(success: true, data: [])); - $this->auditLogger - ->shouldReceive('log') - ->once() - ->andReturn(new AuditLog); - $result = $this->service->restore( $tenant, 'appProtectionPolicy', @@ -187,11 +173,6 @@ )) ->andReturn(new GraphResponse(success: true, data: [])); - $this->auditLogger - ->shouldReceive('log') - ->once() - ->andReturn(new AuditLog); - $result = $this->service->restore( $tenant, 'settingsCatalogPolicy', @@ -245,11 +226,6 @@ )) ->andReturn(new GraphResponse(success: true, data: [])); - $this->auditLogger - ->shouldReceive('log') - ->once() - ->andReturn(new AuditLog); - $result = $this->service->restore( $tenant, 'settingsCatalogPolicy', diff --git a/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php b/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php index 5f98ccd..362b220 100644 --- a/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php +++ b/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php @@ -2,7 +2,8 @@ use App\Models\Tenant; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; +use App\Support\Rbac\UiEnforcement; +use Filament\Actions\Action; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; @@ -18,9 +19,10 @@ ]); } - $enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->preflightByCapability(); + $action = Action::make('test')->action(fn () => null); + + $enforcement = UiEnforcement::forAction($action) + ->requireCapability(Capabilities::TENANT_SYNC); $membershipQueries = 0; @@ -33,4 +35,3 @@ expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue(); expect($membershipQueries)->toBe(1); }); - diff --git a/tests/Unit/Auth/UiEnforcementTest.php b/tests/Unit/Auth/UiEnforcementTest.php index 74b4284..4e8c1fb 100644 --- a/tests/Unit/Auth/UiEnforcementTest.php +++ b/tests/Unit/Auth/UiEnforcementTest.php @@ -2,31 +2,22 @@ use App\Models\Tenant; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Auth\UiTooltips; +use App\Support\Rbac\UiEnforcement; use Filament\Actions\Action; -use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -it('forbids preserveVisibility on record-scoped tenant resolution', function () { - expect(fn () => UiEnforcement::for(Capabilities::TENANT_VIEW)->tenantFromRecord()->preserveVisibility()) - ->toThrow(LogicException::class); - - expect(fn () => UiEnforcement::for(Capabilities::TENANT_VIEW)->preserveVisibility()->tenantFromRecord()) - ->toThrow(LogicException::class); -}); - it('hides actions for non-members on record-scoped surfaces', function () { $tenant = Tenant::factory()->create(); [$user] = createUserWithTenant(); - $action = Action::make('test'); + $action = Action::make('test')->action(fn () => null); - UiEnforcement::for(Capabilities::TENANT_VIEW) - ->tenantFromRecord() - ->apply($action); + UiEnforcement::forAction($action) + ->requireCapability(Capabilities::TENANT_VIEW) + ->apply(); $this->actingAs($user); $action->record($tenant); @@ -38,11 +29,11 @@ $tenant = Tenant::factory()->create(); [$user] = createUserWithTenant($tenant, role: 'readonly'); - $action = Action::make('test'); + $action = Action::make('test')->action(fn () => null); - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->apply($action); + UiEnforcement::forAction($action) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(); $this->actingAs($user); $action->record($tenant); @@ -56,11 +47,11 @@ $tenant = Tenant::factory()->create(); [$user] = createUserWithTenant($tenant, role: 'owner'); - $action = Action::make('test'); + $action = Action::make('test')->action(fn () => null); - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->apply($action); + UiEnforcement::forAction($action) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(); $this->actingAs($user); $action->record($tenant); @@ -70,36 +61,21 @@ expect($action->getTooltip())->toBeNull(); }); -it('supports mixed visibility composition via andVisibleWhen', function () { +it('preserveVisibility combines existing visibility with membership checks', function () { $tenant = Tenant::factory()->create(); [$user] = createUserWithTenant($tenant, role: 'owner'); - Filament::setTenant($tenant, true); + $action = Action::make('test') + ->action(fn () => null) + ->visible(fn (): bool => false); - $action = Action::make('test'); - - UiEnforcement::for(Capabilities::TENANT_VIEW) - ->andVisibleWhen(fn (): bool => false) - ->apply($action); - - $this->actingAs($user); - - expect($action->isHidden())->toBeTrue(); -}); - -it('supports mixed visibility composition via andHiddenWhen', function () { - $tenant = Tenant::factory()->create(); - [$user] = createUserWithTenant($tenant, role: 'owner'); - - Filament::setTenant($tenant, true); - - $action = Action::make('test'); - - UiEnforcement::for(Capabilities::TENANT_VIEW) - ->andHiddenWhen(fn (): bool => true) - ->apply($action); + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_VIEW) + ->apply(); $this->actingAs($user); + $action->record($tenant); expect($action->isHidden())->toBeTrue(); }); @@ -113,9 +89,10 @@ $tenantB->getKey() => ['role' => 'readonly'], ]); - $enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->preflightByCapability(); + $action = Action::make('test')->action(fn () => null); + + $enforcement = UiEnforcement::forAction($action) + ->requireCapability(Capabilities::TENANT_SYNC); expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeFalse(); @@ -125,4 +102,3 @@ expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue(); }); -