diff --git a/ProcessMaker/Contracts/PermissionCacheInterface.php b/ProcessMaker/Contracts/PermissionCacheInterface.php new file mode 100644 index 0000000000..e76dc9286e --- /dev/null +++ b/ProcessMaker/Contracts/PermissionCacheInterface.php @@ -0,0 +1,41 @@ +group = $group; + $this->parentGroup = $parentGroup; + $this->action = $action; + $this->groupMember = $groupMember; + } + + /** + * Get the group that was affected + */ + public function getGroup(): ?Group + { + return $this->group; + } + + /** + * Get the parent group (if any) + */ + public function getParentGroup(): ?Group + { + return $this->parentGroup; + } + + /** + * Get the action performed + */ + public function getAction(): string + { + return $this->action; + } + + /** + * Get the group member record + */ + public function getGroupMember(): ?GroupMember + { + return $this->groupMember; + } + + /** + * Check if this is a removal action + */ + public function isRemoval(): bool + { + return $this->action === 'removed'; + } + + /** + * Check if this is an addition action + */ + public function isAddition(): bool + { + return $this->action === 'added'; + } + + /** + * Check if this is an update action + */ + public function isUpdate(): bool + { + return $this->action === 'updated'; + } +} diff --git a/ProcessMaker/Events/PermissionUpdated.php b/ProcessMaker/Events/PermissionUpdated.php index 08d6f551ef..ea4f077544 100644 --- a/ProcessMaker/Events/PermissionUpdated.php +++ b/ProcessMaker/Events/PermissionUpdated.php @@ -148,4 +148,24 @@ public function getEventName(): string { return 'PermissionUpdated'; } + + /** + * Get the user ID + * + * @return string|null + */ + public function getUserId(): ?string + { + return $this->userId; + } + + /** + * Get the group ID + * + * @return string|null + */ + public function getGroupId(): ?string + { + return $this->groupId; + } } diff --git a/ProcessMaker/Http/Controllers/Api/PermissionController.php b/ProcessMaker/Http/Controllers/Api/PermissionController.php index be05665a3c..5fed46c80b 100644 --- a/ProcessMaker/Http/Controllers/Api/PermissionController.php +++ b/ProcessMaker/Http/Controllers/Api/PermissionController.php @@ -122,16 +122,9 @@ public function update(Request $request) //Sync the entity's permissions with the database $entity->permissions()->sync($permissions->pluck('id')->toArray()); - // Clear user permissions cache and rebuild - $this->clearAndRebuildCache($entity); + // The PermissionUpdated event will automatically trigger cache invalidation + // via the InvalidatePermissionCacheOnUpdate listener return response([], 204); } - - private function clearAndRebuildCache($user) - { - // Rebuild and update the permissions cache - $permissions = $user->permissions()->pluck('name')->toArray(); - Cache::put("user_{$user->id}_permissions", $permissions, 86400); - } } diff --git a/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php b/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php new file mode 100644 index 0000000000..87a76e8954 --- /dev/null +++ b/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php @@ -0,0 +1,44 @@ +permissionService = $permissionService; + } + + /** + * Handle the event. + */ + public function handle(GroupMembershipChanged $event): void + { + try { + $group = $event->getGroup(); + $action = $event->getAction(); + + // All actions (added, removed, updated) require the same cache invalidation logic + // because they all affect the permission hierarchy for the group and its descendants + $this->permissionService->invalidateAll(); + + Log::info("Successfully invalidated permission cache for group hierarchy change: {$action} for group {$group->id}"); + } catch (\Exception $e) { + Log::error('Failed to invalidate permission cache on group hierarchy change', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'group_id' => $event->getGroup()->id ?? 'unknown', + 'action' => $event->getAction(), + ]); + throw $e; + } + } +} diff --git a/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php b/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php new file mode 100644 index 0000000000..cffd42af6a --- /dev/null +++ b/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php @@ -0,0 +1,43 @@ +permissionService = $permissionService; + } + + /** + * Handle the event. + */ + public function handle(PermissionUpdated $event): void + { + try { + // Invalidate cache for user if user permissions were updated + if ($event->getUserId()) { + $this->permissionService->invalidateUserCache((int) $event->getUserId()); + } + + // Invalidate cache for group if group permissions were updated + if ($event->getGroupId()) { + $this->permissionService->invalidateAll(); + } + } catch (\Exception $e) { + Log::error('Failed to invalidate permission cache', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'userId' => $event->getUserId(), + 'groupId' => $event->getGroupId(), + ]); + throw $e; // Re-throw to ensure error is properly handled + } + } +} diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index d59dc4db80..f992010de2 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -195,7 +195,7 @@ public function scheduleTasks() $task->save(); } break; - case 'SCHEDULED_JOB': + case 'SCHEDULED_JOB': $this->executeScheduledJob($config); $task->last_execution = $today->format('Y-m-d H:i:s'); $task->save(); diff --git a/ProcessMaker/Models/GroupMember.php b/ProcessMaker/Models/GroupMember.php index 9ce5a2e8ce..8303513aca 100644 --- a/ProcessMaker/Models/GroupMember.php +++ b/ProcessMaker/Models/GroupMember.php @@ -2,6 +2,8 @@ namespace ProcessMaker\Models; +use ProcessMaker\Observers\GroupMemberObserver; + /** * Represents a group Members definition. * @@ -83,6 +85,30 @@ class GroupMember extends ProcessMakerModel 'group_id', 'member_id', 'member_type', ]; + /** + * Disable soft deletes for this model since the table doesn't have deleted_at column + */ + public function getDeletedAtColumn() + { + return null; + } + + /** + * Disable soft deletes for this model + */ + public static function bootSoftDeletes() + { + // Do nothing - disable soft deletes + } + + /** + * Override the query builder to not use soft deletes + */ + public function newEloquentBuilder($query) + { + return new \Illuminate\Database\Eloquent\Builder($query); + } + public static function rules() { return [ @@ -101,4 +127,14 @@ public function group() { return $this->belongsTo(Group::class); } + + /** + * Boot the model and register observers + */ + protected static function boot() + { + parent::boot(); + + static::observe(GroupMemberObserver::class); + } } diff --git a/ProcessMaker/Observers/GroupMemberObserver.php b/ProcessMaker/Observers/GroupMemberObserver.php new file mode 100644 index 0000000000..05e763f458 --- /dev/null +++ b/ProcessMaker/Observers/GroupMemberObserver.php @@ -0,0 +1,83 @@ +member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) added to group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'added', $groupMember)); + } + } + } + + /** + * Handle the GroupMember "updated" event. + */ + public function updated(GroupMember $groupMember): void + { + // Only handle group-to-group relationships, not user-to-group + if ($groupMember->member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) membership updated in group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'updated', $groupMember)); + } + } + } + + /** + * Handle the GroupMember "deleted" event. + */ + public function deleted(GroupMember $groupMember): void + { + // Only handle group-to-group relationships, not user-to-group + if ($groupMember->member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) removed from group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'removed', $groupMember)); + } + } + } + + /** + * Handle the GroupMember "restored" event. + */ + public function restored(GroupMember $groupMember): void + { + // Only handle group-to-group relationships, not user-to-group + if ($groupMember->member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) restored to group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'added', $groupMember)); + } + } + } +} diff --git a/ProcessMaker/Providers/EventServiceProvider.php b/ProcessMaker/Providers/EventServiceProvider.php index ec8b5fc71e..f2cc0c0832 100644 --- a/ProcessMaker/Providers/EventServiceProvider.php +++ b/ProcessMaker/Providers/EventServiceProvider.php @@ -23,6 +23,7 @@ use ProcessMaker\Events\FilesUpdated; use ProcessMaker\Events\GroupCreated; use ProcessMaker\Events\GroupDeleted; +use ProcessMaker\Events\GroupMembershipChanged; use ProcessMaker\Events\GroupUpdated; use ProcessMaker\Events\GroupUsersUpdated; use ProcessMaker\Events\PermissionUpdated; @@ -65,6 +66,8 @@ use ProcessMaker\Listeners\HandleActivityAssignedInterstitialRedirect; use ProcessMaker\Listeners\HandleActivityCompletedRedirect; use ProcessMaker\Listeners\HandleEndEventRedirect; +use ProcessMaker\Listeners\InvalidatePermissionCacheOnGroupHierarchyChange; +use ProcessMaker\Listeners\InvalidatePermissionCacheOnUpdate; use ProcessMaker\Listeners\InvalidateScreenCacheOnTranslationChange; use ProcessMaker\Listeners\SecurityLogger; use ProcessMaker\Listeners\SessionControlSettingsUpdated; @@ -114,6 +117,12 @@ class EventServiceProvider extends ServiceProvider TranslationChanged::class => [ InvalidateScreenCacheOnTranslationChange::class, ], + PermissionUpdated::class => [ + InvalidatePermissionCacheOnUpdate::class, + ], + GroupMembershipChanged::class => [ + InvalidatePermissionCacheOnGroupHierarchyChange::class, + ], ]; /** diff --git a/ProcessMaker/Providers/PermissionServiceProvider.php b/ProcessMaker/Providers/PermissionServiceProvider.php new file mode 100644 index 0000000000..d5b69ef043 --- /dev/null +++ b/ProcessMaker/Providers/PermissionServiceProvider.php @@ -0,0 +1,31 @@ +app->bind(PermissionRepositoryInterface::class, PermissionRepository::class); + $this->app->bind(PermissionCacheInterface::class, PermissionCacheService::class); + + // Bind the service manager as a singleton + $this->app->singleton(PermissionServiceManager::class, function ($app) { + return new PermissionServiceManager( + $app->make(PermissionRepositoryInterface::class), + $app->make(PermissionCacheInterface::class) + ); + }); + } +} diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 1c18838a0d..08037acbd2 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -40,6 +40,7 @@ use ProcessMaker\Multitenancy\Tenant; use ProcessMaker\Observers; use ProcessMaker\PolicyExtension; +use ProcessMaker\Providers\PermissionServiceProvider; use ProcessMaker\Repositories\SettingsConfigRepository; use RuntimeException; use Spatie\Multitenancy\Events\MadeTenantCurrentEvent; @@ -172,6 +173,9 @@ public function register(): void $this->app->register(DuskServiceProvider::class); } + // Register our permission services + $this->app->register(PermissionServiceProvider::class); + $this->app->singleton(Managers\PackageManager::class, function () { return new Managers\PackageManager(); }); diff --git a/ProcessMaker/Repositories/CaseApiRepository.php b/ProcessMaker/Repositories/CaseApiRepository.php index 4d4918217f..0f18942375 100644 --- a/ProcessMaker/Repositories/CaseApiRepository.php +++ b/ProcessMaker/Repositories/CaseApiRepository.php @@ -34,7 +34,7 @@ class CaseApiRepository implements CaseApiRepositoryInterface 'completed_at', 'last_stage_id', 'last_stage_name', - 'progress' + 'progress', ]; protected $sortableFields = [ diff --git a/ProcessMaker/Repositories/PermissionRepository.php b/ProcessMaker/Repositories/PermissionRepository.php new file mode 100644 index 0000000000..694bece228 --- /dev/null +++ b/ProcessMaker/Repositories/PermissionRepository.php @@ -0,0 +1,264 @@ +find($userId); + + if (!$user) { + return []; + } + + $permissions = []; + + // Add direct user permissions + if ($user->permissions) { + foreach ($user->permissions as $permission) { + $permissions[] = $permission->name; + } + } + + // Add group permissions (including nested groups through recursion) + foreach ($user->groupMembersFromMemberable as $groupMember) { + $group = $groupMember->group; + if ($group && $group->permissions) { + foreach ($group->permissions as $permission) { + $permissions[] = $permission->name; + } + + // Get nested group permissions recursively + $nestedPermissions = $this->getNestedGroupPermissionsOptimized($group); + $permissions = array_merge($permissions, $nestedPermissions); + } + } + + return array_unique($permissions); + } + + /** + * Get direct user permissions + */ + public function getDirectUserPermissions(int $userId): array + { + $user = User::find($userId); + if (!$user) { + return []; + } + + return $user->permissions()->pluck('name')->toArray(); + } + + /** + * Get group permissions for a user (optimized with hierarchical inheritance) + */ + public function getGroupPermissions(int $userId): array + { + $user = User::with([ + 'groupMembersFromMemberable.group.permissions', + ])->find($userId); + + if (!$user) { + return []; + } + + $permissions = []; + + // Add group permissions (including nested groups through recursion) + foreach ($user->groupMembersFromMemberable as $groupMember) { + $group = $groupMember->group; + if ($group && $group->permissions) { + foreach ($group->permissions as $permission) { + $permissions[] = $permission->name; + } + + // Get nested group permissions recursively + $nestedPermissions = $this->getNestedGroupPermissionsOptimized($group); + $permissions = array_merge($permissions, $nestedPermissions); + } + } + + return array_unique($permissions); + } + + /** + * Check if user has a specific permission (optimized with hierarchical inheritance) + */ + public function userHasPermission(int $userId, string $permission): bool + { + $user = User::with([ + 'permissions', + 'groupMembersFromMemberable.group.permissions', + ])->find($userId); + + if (!$user) { + return false; + } + + // Check direct user permissions + if ($user->permissions) { + foreach ($user->permissions as $userPermission) { + if ($userPermission->name === $permission) { + return true; + } + } + } + + // Check group permissions (including nested groups through recursion) + foreach ($user->groupMembersFromMemberable as $groupMember) { + $group = $groupMember->group; + if ($group) { + // Check direct group permissions + if ($group->permissions) { + foreach ($group->permissions as $groupPermission) { + if ($groupPermission->name === $permission) { + return true; + } + } + } + + // Check nested group permissions recursively + if ($this->hasNestedGroupPermission($group, $permission)) { + return true; + } + } + } + + return false; + } + + /** + * Get permissions for a specific group + */ + public function getGroupPermissionsById(int $groupId): array + { + $group = Group::find($groupId); + if (!$group) { + return []; + } + + return $group->permissions()->pluck('name')->toArray(); + } + + /** + * Get nested group permissions (optimized recursive) + */ + public function getNestedGroupPermissions(int $groupId): array + { + $group = Group::find($groupId); + if (!$group) { + return []; + } + + $permissions = []; + + // Get direct group permissions + $groupPermissions = $group->permissions()->pluck('name')->toArray(); + $permissions = array_merge($permissions, $groupPermissions); + + // Get nested group permissions recursively from parent groups + foreach ($group->groupMembersFromMemberable as $member) { + if ($member->member_type === Group::class && $member->group) { + // Recurse on the parent group (group_id), not the current group (member_id) + $nestedPermissions = $this->getNestedGroupPermissions($member->group->id); + $permissions = array_merge($permissions, $nestedPermissions); + } + } + + return array_unique($permissions); + } + + /** + * Get nested group permissions recursively with protection against infinite loops + */ + private function getNestedGroupPermissionsOptimized(Group $group, array $visitedGroups = [], int $maxDepth = 10): array + { + // Protection against infinite recursion + if (in_array($group->id, $visitedGroups) || count($visitedGroups) >= $maxDepth) { + return []; + } + + $permissions = []; + $newVisitedGroups = array_merge($visitedGroups, [$group->id]); + + // Get groups that have this group as a member + // Load the relationship if not already loaded + if (!$group->relationLoaded('groupMembersFromMemberable')) { + $group->load(['groupMembersFromMemberable.group.permissions']); + } + + foreach ($group->groupMembersFromMemberable as $member) { + if ($member->member_type === Group::class && $member->group) { + $nestedGroup = $member->group; + + // Add direct permissions from nested group + if ($nestedGroup->permissions) { + foreach ($nestedGroup->permissions as $permission) { + $permissions[] = $permission->name; + } + } + + // Recursively get permissions from deeper nested groups + $deeperPermissions = $this->getNestedGroupPermissionsOptimized($nestedGroup, $newVisitedGroups, $maxDepth); + $permissions = array_merge($permissions, $deeperPermissions); + } + } + + return $permissions; + } + + /** + * Check if a group has a specific permission through nested inheritance + */ + private function hasNestedGroupPermission(Group $group, string $permission, array $visitedGroups = [], int $maxDepth = 10): bool + { + // Protection against infinite recursion + if (in_array($group->id, $visitedGroups) || count($visitedGroups) >= $maxDepth) { + return false; + } + + $newVisitedGroups = array_merge($visitedGroups, [$group->id]); + + // Load the relationship if not already loaded + if (!$group->relationLoaded('groupMembersFromMemberable')) { + $group->load(['groupMembersFromMemberable.group.permissions']); + } + + foreach ($group->groupMembersFromMemberable as $member) { + if ($member->member_type === Group::class && $member->group) { + $nestedGroup = $member->group; + + // Check direct permissions from nested group + if ($nestedGroup->permissions) { + foreach ($nestedGroup->permissions as $groupPermission) { + if ($groupPermission->name === $permission) { + return true; + } + } + } + + // Recursively check permissions from deeper nested groups + if ($this->hasNestedGroupPermission($nestedGroup, $permission, $newVisitedGroups, $maxDepth)) { + return true; + } + } + } + + return false; + } +} diff --git a/ProcessMaker/Services/PermissionCacheService.php b/ProcessMaker/Services/PermissionCacheService.php new file mode 100644 index 0000000000..167f7be0e8 --- /dev/null +++ b/ProcessMaker/Services/PermissionCacheService.php @@ -0,0 +1,163 @@ +getUserPermissionsKey($userId); + + try { + return Cache::get($key); + } catch (\Exception $e) { + Log::warning("Failed to get cached user permissions for user {$userId}: " . $e->getMessage()); + + return null; + } + } + + /** + * Cache user permissions + */ + public function cacheUserPermissions(int $userId, array $permissions): void + { + $key = $this->getUserPermissionsKey($userId); + + try { + Cache::put($key, $permissions, self::USER_PERMISSIONS_TTL); + } catch (\Exception $e) { + Log::warning("Failed to cache user permissions for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Get cached permissions for a group + */ + public function getGroupPermissions(int $groupId): ?array + { + $key = $this->getGroupPermissionsKey($groupId); + + try { + return Cache::get($key); + } catch (\Exception $e) { + Log::warning("Failed to get cached group permissions for group {$groupId}: " . $e->getMessage()); + + return null; + } + } + + /** + * Cache group permissions + */ + public function cacheGroupPermissions(int $groupId, array $permissions): void + { + $key = $this->getGroupPermissionsKey($groupId); + + try { + Cache::put($key, $permissions, self::GROUP_PERMISSIONS_TTL); + } catch (\Exception $e) { + Log::warning("Failed to cache group permissions for group {$groupId}: " . $e->getMessage()); + } + } + + /** + * Invalidate user permissions cache + */ + public function invalidateUserPermissions(int $userId): void + { + $key = $this->getUserPermissionsKey($userId); + + try { + Cache::forget($key); + } catch (\Exception $e) { + Log::warning("Failed to invalidate user permissions cache for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Invalidate group permissions cache + */ + public function invalidateGroupPermissions(int $groupId): void + { + $key = $this->getGroupPermissionsKey($groupId); + + try { + Cache::forget($key); + } catch (\Exception $e) { + Log::warning("Failed to invalidate group permissions cache for group {$groupId}: " . $e->getMessage()); + } + } + + /** + * Clear all permission caches + */ + public function clearAll(): void + { + try { + // Clear all permission-related caches + Cache::flush(); + } catch (\Exception $e) { + Log::warning('Failed to clear all permission caches: ' . $e->getMessage()); + } + } + + /** + * Get cache key for user permissions + */ + private function getUserPermissionsKey(int $userId): string + { + return self::USER_PERMISSIONS_KEY . ":{$userId}"; + } + + /** + * Get cache key for group permissions + */ + private function getGroupPermissionsKey(int $groupId): string + { + return self::GROUP_PERMISSIONS_KEY . ":{$groupId}"; + } + + /** + * Warm up cache for a user + */ + public function warmUpUserCache(int $userId, array $permissions): void + { + $this->cacheUserPermissions($userId, $permissions); + } + + /** + * Warm up cache for a group + */ + public function warmUpGroupCache(int $groupId, array $permissions): void + { + $this->cacheGroupPermissions($groupId, $permissions); + } + + /** + * Get cache statistics + */ + public function getCacheStats(): array + { + return [ + 'user_permissions_ttl' => self::USER_PERMISSIONS_TTL, + 'group_permissions_ttl' => self::GROUP_PERMISSIONS_TTL, + 'cache_driver' => config('cache.default'), + ]; + } +} diff --git a/ProcessMaker/Services/PermissionServiceManager.php b/ProcessMaker/Services/PermissionServiceManager.php new file mode 100644 index 0000000000..9d5b53d6fb --- /dev/null +++ b/ProcessMaker/Services/PermissionServiceManager.php @@ -0,0 +1,116 @@ +repository = $repository; + $this->cacheService = $cacheService; + + // Register default strategy + $this->defaultStrategy = new CachedPermissionStrategy($cacheService, $repository); + } + + /** + * Get all permissions for a user + */ + public function getUserPermissions(int $userId): array + { + // Try cache first + $cachedPermissions = $this->cacheService->getUserPermissions($userId); + + if ($cachedPermissions !== null) { + return $cachedPermissions; + } + + // Get from repository and cache + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + + return $permissions; + } + + /** + * Check if user has permission + */ + public function userHasPermission(int $userId, string $permission): bool + { + return $this->defaultStrategy->hasPermission($userId, $permission); + } + + /** + * Check if user has any of the given permissions + */ + public function userHasAnyPermission(int $userId, array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->userHasPermission($userId, $permission)) { + return true; + } + } + + return false; + } + + /** + * Check if user has all of the given permissions + */ + public function userHasAllPermissions(int $userId, array $permissions): bool + { + foreach ($permissions as $permission) { + if (!$this->userHasPermission($userId, $permission)) { + return false; + } + } + + return true; + } + + /** + * Warm up cache for a user + */ + public function warmUpUserCache(int $userId): void + { + try { + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + + Log::info("Warmed up permission cache for user {$userId}"); + } catch (\Exception $e) { + Log::error("Failed to warm up permission cache for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Invalidate cache for a user + */ + public function invalidateUserCache(int $userId): void + { + $this->cacheService->invalidateUserPermissions($userId); + Log::info("Invalidated permission cache for user {$userId}"); + } + + /** + * Invalidate all permission caches + */ + public function invalidateAll(): void + { + $this->cacheService->clearAll(); + } +} diff --git a/ProcessMaker/Services/PermissionStrategies/CachedPermissionStrategy.php b/ProcessMaker/Services/PermissionStrategies/CachedPermissionStrategy.php new file mode 100644 index 0000000000..9d4860c9ee --- /dev/null +++ b/ProcessMaker/Services/PermissionStrategies/CachedPermissionStrategy.php @@ -0,0 +1,75 @@ +cacheService = $cacheService; + $this->repository = $repository; + } + + /** + * Check if user has permission using cached strategy + */ + public function hasPermission(int $userId, string $permission): bool + { + // First, try to get from cache + $cachedPermissions = $this->cacheService->getUserPermissions($userId); + + if ($cachedPermissions !== null) { + return in_array($permission, $cachedPermissions); + } + + // If not in cache, get from repository and cache + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + + return in_array($permission, $permissions); + } + + /** + * Get strategy name for identification + */ + public function getStrategyName(): string + { + return 'cached'; + } + + /** + * Check if this strategy can handle the permission check + */ + public function canHandle(string $permission): bool + { + // This strategy can handle all permission types + return true; + } + + /** + * Warm up cache for a user + */ + public function warmUpCache(int $userId): void + { + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + } + + /** + * Invalidate cache for a user + */ + public function invalidateCache(int $userId): void + { + $this->cacheService->invalidateUserPermissions($userId); + } +} diff --git a/ProcessMaker/Traits/HasAuthorization.php b/ProcessMaker/Traits/HasAuthorization.php index d699b403ae..591102afa2 100644 --- a/ProcessMaker/Traits/HasAuthorization.php +++ b/ProcessMaker/Traits/HasAuthorization.php @@ -6,15 +6,28 @@ use Illuminate\Support\Facades\Log; use ProcessMaker\Models\Group; use ProcessMaker\Models\Permission; +use ProcessMaker\Services\PermissionServiceManager; trait HasAuthorization { + private ?PermissionServiceManager $permissionService = null; + + /** + * Get or create permission service manager + */ + private function getPermissionService(): PermissionServiceManager + { + if ($this->permissionService === null) { + $this->permissionService = app(PermissionServiceManager::class); + } + + return $this->permissionService; + } + public function loadPermissions() { - return array_merge( - $this->loadUserPermissions(), - $this->loadGroupPermissions() - ); + // Use the new optimized service + return $this->getPermissionService()->getUserPermissions($this->id); } public function loadUserPermissions() @@ -74,9 +87,16 @@ public function loadPermissionOfGroups(Group $group, array $permissions = [], ar public function hasPermission($permissionString) { - $permissionStrings = $this->loadPermissions(); + // Use the new optimized service for permission checking + return $this->getPermissionService()->userHasPermission($this->id, $permissionString); + } - return in_array($permissionString, $permissionStrings); + /** + * Invalidate permission cache for this user + */ + public function invalidatePermissionCache(): void + { + $this->getPermissionService()->invalidateUserCache($this->id); } /** @@ -113,5 +133,8 @@ public function giveDirectPermission($permissionNames) $permissionId = Permission::byName($permissionName)->id; $this->permissions()->attach($permissionId); } + + // Invalidate cache after giving new permissions + $this->invalidatePermissionCache(); } } diff --git a/ProcessMaker/Traits/HasComments.php b/ProcessMaker/Traits/HasComments.php index 833e4207f7..e0a0969f76 100644 --- a/ProcessMaker/Traits/HasComments.php +++ b/ProcessMaker/Traits/HasComments.php @@ -25,7 +25,7 @@ public function comments() * @param string|null $subject * @param string $type * @param string|null $caseNumber - * @return \ProcessMaker\Models\Comment + * @return Comment */ public function addComment(string $body, ?User $user = null, ?string $subject = null, string $type = 'LOG', ?string $caseNumber = null) { diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index bf9809ebf3..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# docs - - - diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php index e12c036037..166d5e394f 100644 --- a/tests/Feature/Admin/DashboardTest.php +++ b/tests/Feature/Admin/DashboardTest.php @@ -35,12 +35,14 @@ public function testIndexRoute() $this->user->permissions()->attach(Permission::byName('view-users')); $this->user->permissions()->attach(Permission::byName('view-groups')); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $response = $this->webCall('GET', '/admin'); $response->assertRedirect(route('users.index')); $this->user->permissions()->detach(Permission::byName('view-users')); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $this->flushSession(); $response = $this->webCall('GET', '/admin'); diff --git a/tests/Feature/Api/PermissionsTest.php b/tests/Feature/Api/PermissionsTest.php index 2e7b869113..196ed18f3d 100644 --- a/tests/Feature/Api/PermissionsTest.php +++ b/tests/Feature/Api/PermissionsTest.php @@ -71,6 +71,9 @@ public function testApiPermissions() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure changes take effect + $this->user->invalidatePermissionCache(); + $response = $this->apiCall('DELETE', '/processes/' . $process->id); $response->assertStatus(403); @@ -78,6 +81,9 @@ public function testApiPermissions() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + $response = $this->apiCall('DELETE', '/processes/' . $process->id); $response->assertStatus(204); } @@ -152,6 +158,9 @@ public function testCategoryPermission() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + // test create permission $response = $this->apiCall('POST', $url, $attrs); $response->assertStatus(201); @@ -168,6 +177,9 @@ public function testCategoryPermission() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + $response = $this->apiCall('PUT', $url, $attrs); $this->assertEquals('Test Category Update', $class::find($id)->name); @@ -185,6 +197,9 @@ public function testCategoryPermission() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + $url = route("api.{$type}_categories.index"); $response = $this->apiCall('GET', $url); $response->assertStatus(200); diff --git a/tests/Feature/Api/ProcessRequestsTest.php b/tests/Feature/Api/ProcessRequestsTest.php index 280ee9c97a..f216acd653 100644 --- a/tests/Feature/Api/ProcessRequestsTest.php +++ b/tests/Feature/Api/ProcessRequestsTest.php @@ -313,6 +313,7 @@ public function testListRequestViewAllPermission() $this->user->giveDirectPermission('view-all_requests'); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $response = $this->apiCall('GET', self::API_TEST_URL); $json = $response->json(); @@ -565,6 +566,7 @@ public function testCompleteRequest() $response->assertStatus(204); $request->refresh(); + $this->user->invalidatePermissionCache(); $this->assertEquals('COMPLETED', $request->status); // Verify comment added @@ -814,6 +816,7 @@ public function testUserCanEditCompletedData() $editAllRequestsData = Permission::where('name', 'edit-request_data')->first(); $this->user->permissions()->attach($editAllRequestsData); $this->user->refresh(); + $this->user->invalidatePermissionCache(); session()->forget('permissions'); $response = $this->apiCall('put', $url, ['data' => ['foo' => '123']]); @@ -823,6 +826,7 @@ public function testUserCanEditCompletedData() $this->user->permissions()->detach($editAllRequestsData); $this->user->refresh(); + $this->user->invalidatePermissionCache(); session()->forget('permissions'); $response = $this->apiCall('put', $url, ['data' => ['foo' => '123']]); @@ -980,6 +984,7 @@ public function testGetRequestToken() $this->user->giveDirectPermission('view-all_requests'); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Verify with other user with permissions $response = $this->apiCall('GET', route('api.requests.getRequestToken', ['request' => $request->id, 'element_id' => $token->element_id])); diff --git a/tests/Feature/PermissionCacheInvalidationTest.php b/tests/Feature/PermissionCacheInvalidationTest.php new file mode 100644 index 0000000000..c27e9bfcc9 --- /dev/null +++ b/tests/Feature/PermissionCacheInvalidationTest.php @@ -0,0 +1,117 @@ +user) { + $this->user = User::factory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make('password'), + 'is_administrator' => true, + ]); + } + + // Ensure the user has the edit-users permission for the API call + $editUsersPermission = Permission::where('name', 'edit-users')->first(); + if ($editUsersPermission) { + $this->user->permissions()->attach($editUsersPermission->id); + } + + $this->permissionService = app(PermissionServiceManager::class); + } + + public function test_permission_cache_is_invalidated_when_permissions_updated() + { + // Create test permissions + $permission1 = Permission::factory()->create([ + 'name' => 'test-permission', + 'title' => 'Test Permission', + ]); + + $permission2 = Permission::factory()->create([ + 'name' => 'another-permission', + 'title' => 'Another Permission', + ]); + + // Give permission to user + $this->user->permissions()->attach($permission1->id); + + // Warm up the cache + $this->permissionService->warmUpUserCache($this->user->id); + + // Verify permission is cached + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNotNull($cachedPermissions); + $this->assertContains('test-permission', $cachedPermissions); + + // Update permissions via API + $response = $this->apiCall('PUT', '1.0/permissions', [ + 'user_id' => $this->user->id, + 'permission_names' => ['test-permission', 'another-permission'], + ]); + + $this->assertEquals(204, $response->getStatusCode()); + + // Verify cache is invalidated + $cachedPermissionsAfterUpdate = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNull($cachedPermissionsAfterUpdate); + + // Verify new permissions are loaded from database + $freshPermissions = $this->permissionService->getUserPermissions($this->user->id); + $this->assertContains('test-permission', $freshPermissions); + $this->assertContains('another-permission', $freshPermissions); + } + + public function test_permission_cache_is_invalidated_when_user_permissions_removed() + { + // Create test permissions + $permission1 = Permission::factory()->create(['name' => 'permission-1']); + $permission2 = Permission::factory()->create(['name' => 'permission-2']); + + // Give both permissions to user + $this->user->permissions()->attach([$permission1->id, $permission2->id]); + + // Warm up the cache + $this->permissionService->warmUpUserCache($this->user->id); + + // Verify permissions are cached + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNotNull($cachedPermissions); + $this->assertContains('permission-1', $cachedPermissions); + $this->assertContains('permission-2', $cachedPermissions); + + // Remove one permission via API + $response = $this->apiCall('PUT', '1.0/permissions', [ + 'user_id' => $this->user->id, + 'permission_names' => ['permission-1'], + ]); + + $this->assertEquals(204, $response->getStatusCode()); + + // Verify cache is invalidated + $cachedPermissionsAfterUpdate = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNull($cachedPermissionsAfterUpdate); + + // Verify updated permissions are loaded from database + $freshPermissions = $this->permissionService->getUserPermissions($this->user->id); + $this->assertContains('permission-1', $freshPermissions); + $this->assertNotContains('permission-2', $freshPermissions); + } +} diff --git a/tests/Feature/PermissionsTest.php b/tests/Feature/PermissionsTest.php index 9f1bdf83fb..5e7af55fed 100644 --- a/tests/Feature/PermissionsTest.php +++ b/tests/Feature/PermissionsTest.php @@ -82,6 +82,7 @@ public function testSetPermissionsForGroup() // Attach the permission to our group. $group->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our group now has permission, so this should return 200. $response = $this->webCall('GET', $url); diff --git a/tests/Feature/ProcessesTest.php b/tests/Feature/ProcessesTest.php index 19474ef29a..17ec5346ea 100644 --- a/tests/Feature/ProcessesTest.php +++ b/tests/Feature/ProcessesTest.php @@ -47,6 +47,7 @@ public function testIndex() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('GET', $url); @@ -73,6 +74,7 @@ public function testEdit() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('GET', $url); @@ -99,6 +101,7 @@ public function testCreate() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('GET', $url); @@ -129,6 +132,7 @@ public function testStore() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('POST', $url, $data); @@ -158,6 +162,7 @@ public function testUpdate() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('PUT', $url, $data); @@ -183,6 +188,7 @@ public function testArchive() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('DELETE', $url); @@ -202,6 +208,7 @@ public function testIndexPermissionRedirect() $this->user->permissions()->attach(Permission::byName($perm)); } $this->user->refresh(); + $this->user->invalidatePermissionCache(); $response = $this->webCall('GET', '/processes'); $response->assertViewIs('processes.index'); @@ -209,6 +216,7 @@ public function testIndexPermissionRedirect() $checkNextAuth = function ($perm, $nextRoute) { $this->user->permissions()->detach(Permission::byName($perm)); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $this->flushSession(); $response = $this->webCall('GET', '/processes'); $response->assertRedirect(route($nextRoute)); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php index a7e9fed9d1..666ee2b569 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/ProfileTest.php @@ -35,6 +35,7 @@ public function testEditRoute(): void $user->is_administrator = true; $user->save(); $user->refresh(); + $user->invalidatePermissionCache(); // invalidate permission cache // Our user now has permissions, so this should return 200. $this->assertTrue($user->hasPermission('edit-personal-profile')); @@ -83,6 +84,7 @@ public function testEditProfileGroupPermission(): void $user->is_administrator = true; $user->save(); $user->refresh(); + $user->invalidatePermissionCache(); // Our group now has permission, so this should return 200. $this->assertTrue($user->hasPermission('edit-personal-profile')); diff --git a/tests/Model/UserTest.php b/tests/Model/UserTest.php index 6c157aef2c..0afa3e146d 100644 --- a/tests/Model/UserTest.php +++ b/tests/Model/UserTest.php @@ -83,6 +83,9 @@ public function testCanAnyFirst() $user->permissions()->attach($p3); $user->refresh(); + // Invalidate permission cache to ensure the new permissions take effect + $user->invalidatePermissionCache(); + $this->assertTrue($user->can('bar')); $this->assertEquals('bar', $user->canAnyFirst('foo|bar')); $this->assertEquals('baz', $user->canAnyFirst('foo|baz')); @@ -112,6 +115,11 @@ public function testAddCategoryViewPermissions() $user->permissions()->attach($perm); $user->refresh(); + // Invalidate permission cache to ensure the new permissions take effect + $user->invalidatePermissionCache(); + + $user->permissions()->attach($viewCatPerm); + // $user->invalidatePermissionCache(); $this->assertTrue($user->can($viewCatPerm->name)); $this->assertFalse($user->can($editCatePerm->name)); diff --git a/tests/unit/ProcessMaker/Events/GroupMembershipChangedTest.php b/tests/unit/ProcessMaker/Events/GroupMembershipChangedTest.php new file mode 100644 index 0000000000..6d6110e7d2 --- /dev/null +++ b/tests/unit/ProcessMaker/Events/GroupMembershipChangedTest.php @@ -0,0 +1,172 @@ +group = Group::factory()->create(['name' => 'Test Group']); + $this->parentGroup = Group::factory()->create(['name' => 'Parent Group']); + + // Create a group member relationship + $this->groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + } + + /** + * Test event creation with all parameters + */ + public function test_event_creation_with_all_parameters() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'added', + $this->groupMember + ); + + $this->assertInstanceOf(GroupMembershipChanged::class, $event); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertEquals($this->parentGroup->id, $event->getParentGroup()->id); + $this->assertEquals('added', $event->getAction()); + $this->assertEquals($this->groupMember->id, $event->getGroupMember()->id); + } + + /** + * Test event creation without group member + */ + public function test_event_creation_without_group_member() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'removed' + ); + + $this->assertInstanceOf(GroupMembershipChanged::class, $event); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertEquals($this->parentGroup->id, $event->getParentGroup()->id); + $this->assertEquals('removed', $event->getAction()); + $this->assertNull($event->getGroupMember()); + } + + /** + * Test event creation with null parent group + */ + public function test_event_creation_with_null_parent_group() + { + $event = new GroupMembershipChanged( + $this->group, + null, + 'updated' + ); + + $this->assertInstanceOf(GroupMembershipChanged::class, $event); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertNull($event->getParentGroup()); + $this->assertEquals('updated', $event->getAction()); + } + + /** + * Test action checking methods + */ + public function test_action_checking_methods() + { + $addedEvent = new GroupMembershipChanged($this->group, $this->parentGroup, 'added'); + $removedEvent = new GroupMembershipChanged($this->group, $this->parentGroup, 'removed'); + $updatedEvent = new GroupMembershipChanged($this->group, $this->parentGroup, 'updated'); + + // Test isAddition method + $this->assertTrue($addedEvent->isAddition()); + $this->assertFalse($removedEvent->isAddition()); + $this->assertFalse($updatedEvent->isAddition()); + + // Test isRemoval method + $this->assertFalse($addedEvent->isRemoval()); + $this->assertTrue($removedEvent->isRemoval()); + $this->assertFalse($updatedEvent->isRemoval()); + + // Test isUpdate method + $this->assertFalse($addedEvent->isUpdate()); + $this->assertFalse($removedEvent->isUpdate()); + $this->assertTrue($updatedEvent->isUpdate()); + } + + /** + * Test event serialization + */ + public function test_event_serialization() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'added', + $this->groupMember + ); + + // Test that the event can be serialized (for queue processing) + $serialized = serialize($event); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(GroupMembershipChanged::class, $unserialized); + $this->assertEquals($event->getAction(), $unserialized->getAction()); + $this->assertEquals($event->getGroup()->id, $unserialized->getGroup()->id); + $this->assertEquals($event->getParentGroup()->id, $unserialized->getParentGroup()->id); + } + + /** + * Test event with different action types + */ + public function test_event_with_different_action_types() + { + $actions = ['added', 'removed', 'updated', 'restored']; + + foreach ($actions as $action) { + $event = new GroupMembershipChanged($this->group, $this->parentGroup, $action); + + $this->assertEquals($action, $event->getAction()); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertEquals($this->parentGroup->id, $event->getParentGroup()->id); + } + } + + /** + * Test event properties are accessible + */ + public function test_event_properties_are_accessible() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'added', + $this->groupMember + ); + + // Test public properties are accessible + $this->assertEquals($this->group->id, $event->group->id); + $this->assertEquals($this->parentGroup->id, $event->parentGroup->id); + $this->assertEquals('added', $event->action); + $this->assertEquals($this->groupMember->id, $event->groupMember->id); + } +} diff --git a/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php new file mode 100644 index 0000000000..40ade6a6a0 --- /dev/null +++ b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php @@ -0,0 +1,177 @@ +listener = new InvalidatePermissionCacheOnGroupHierarchyChange( + app(\ProcessMaker\Services\PermissionServiceManager::class) + ); + + // Create test data + $this->createTestData(); + + // Clear cache + Cache::flush(); + } + + /** + * Create test data for the tests + */ + private function createTestData(): void + { + // Create permissions + $permission = Permission::factory()->create(['name' => 'test-permission']); + + // Create groups + $this->group = Group::factory()->create(['name' => 'Child Group']); + $this->parentGroup = Group::factory()->create(['name' => 'Parent Group']); + + // Create user + $this->user = User::factory()->create(['username' => 'testuser']); + + // Set up hierarchy: User → Group → ParentGroup + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group->groupMembersFromMemberable()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Add permission to parent group + $this->parentGroup->permissions()->attach($permission->id); + } + + /** + * Test that the listener can handle events without throwing type errors + */ + public function test_listener_handles_events_without_type_errors() + { + // Test all action types + $actions = ['added', 'removed', 'updated', 'restored']; + + foreach ($actions as $action) { + // Create the event + $event = new GroupMembershipChanged($this->group, $this->parentGroup, $action); + + // Handle the event - should not throw any exceptions or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + } + + /** + * Test that circular references are handled safely + */ + public function test_handles_circular_references_safely() + { + // Create a circular reference: Group A → Group B → Group A + $groupA = Group::factory()->create(['name' => 'Group A']); + $groupB = Group::factory()->create(['name' => 'Group B']); + + // Create circular relationship + $groupA->groupMembersFromMemberable()->create([ + 'group_id' => $groupB->id, + 'member_id' => $groupA->id, + 'member_type' => Group::class, + ]); + + $groupB->groupMembersFromMemberable()->create([ + 'group_id' => $groupA->id, + 'member_id' => $groupB->id, + 'member_type' => Group::class, + ]); + + // Create the event + $event = new GroupMembershipChanged($groupA, $groupB, 'removed'); + + // Handle the event - should not cause infinite recursion or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + + /** + * Test that deep hierarchies are handled safely + */ + public function test_handles_deep_hierarchies_safely() + { + // Create a deep hierarchy: Group1 → Group2 → Group3 → Group4 → Group5 + $groups = []; + for ($i = 1; $i <= 5; $i++) { + $groups[$i] = Group::factory()->create(['name' => "Group {$i}"]); + } + + // Create deep hierarchy + for ($i = 1; $i < 5; $i++) { + $groups[$i]->groupMembersFromMemberable()->create([ + 'group_id' => $groups[$i + 1]->id, + 'member_id' => $groups[$i]->id, + 'member_type' => Group::class, + ]); + } + + // Create the event for the top group + $event = new GroupMembershipChanged($groups[1], $groups[2], 'removed'); + + // Handle the event - should not cause infinite recursion or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + + /** + * Test that the listener handles missing groups gracefully + */ + public function test_handles_missing_groups_gracefully() + { + // Create an event with a non-existent group + $nonExistentGroup = new Group(['id' => 99999, 'name' => 'Non-existent Group']); + $event = new GroupMembershipChanged($nonExistentGroup, $this->parentGroup, 'removed'); + + // Handle the event - should not throw any exceptions or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + + /** + * Test that the listener can handle events with null parent group + */ + public function test_handles_events_with_null_parent_group() + { + // Create the event with null parent group + $event = new GroupMembershipChanged($this->group, null, 'updated'); + + // Handle the event - should not throw any exceptions or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } +} diff --git a/tests/unit/ProcessMaker/Observers/GroupMemberObserverTest.php b/tests/unit/ProcessMaker/Observers/GroupMemberObserverTest.php new file mode 100644 index 0000000000..6cecdec0c5 --- /dev/null +++ b/tests/unit/ProcessMaker/Observers/GroupMemberObserverTest.php @@ -0,0 +1,203 @@ +observer = new GroupMemberObserver(); + + // Create test groups + $this->group = Group::factory()->create(['name' => 'Test Group']); + $this->parentGroup = Group::factory()->create(['name' => 'Parent Group']); + + // Create test user + $this->user = User::factory()->create(['username' => 'testuser']); + + // Fake events to test if they are dispatched + Event::fake(); + } + + /** + * Test that GroupMembershipChanged event is dispatched when a group is added to another group + */ + public function test_dispatches_event_when_group_added_to_group() + { + // Create a group member relationship (group added to parent group) + $groupMember = new GroupMember([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the created event + $this->observer->created($groupMember); + + // Assert that the event was dispatched + Event::assertDispatched(GroupMembershipChanged::class, function ($event) use ($groupMember) { + return $event->getGroup()->id === $this->group->id && + $event->getParentGroup()->id === $this->parentGroup->id && + $event->getAction() === 'added' && + $event->getGroupMember()->id === $groupMember->id; + }); + } + + /** + * Test that GroupMembershipChanged event is dispatched when a group is removed from another group + */ + public function test_dispatches_event_when_group_removed_from_group() + { + // Create a group member relationship + $groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the deleted event + $this->observer->deleted($groupMember); + + // Assert that the event was dispatched + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getGroup()->id === $this->group->id && + $event->getParentGroup()->id === $this->parentGroup->id && + $event->getAction() === 'removed'; + }); + } + + /** + * Test that GroupMembershipChanged event is dispatched when a group membership is updated + */ + public function test_dispatches_event_when_group_membership_updated() + { + // Create a group member relationship + $groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the updated event + $this->observer->updated($groupMember); + + // Assert that the event was dispatched + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getGroup()->id === $this->group->id && + $event->getParentGroup()->id === $this->parentGroup->id && + $event->getAction() === 'updated'; + }); + } + + /** + * Test that event is not dispatched for user memberships (only group memberships) + */ + public function test_does_not_dispatch_event_for_user_memberships() + { + // Create a user member relationship + $userMember = new GroupMember([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + // Trigger the created event + $this->observer->created($userMember); + + // Assert that no GroupMembershipChanged event was dispatched + Event::assertNotDispatched(GroupMembershipChanged::class); + } + + /** + * Test that event is not dispatched for invalid group relationships + */ + public function test_does_not_dispatch_event_for_invalid_group_relationships() + { + // Create a group member with non-existent group + $groupMember = new GroupMember([ + 'group_id' => 99999, // Non-existent group ID + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the created event + $this->observer->created($groupMember); + + // Assert that no GroupMembershipChanged event was dispatched + Event::assertNotDispatched(GroupMembershipChanged::class); + } + + /** + * Test that event is not dispatched for invalid member groups + */ + public function test_does_not_dispatch_event_for_invalid_member_groups() + { + // Create a group member with non-existent member group + $groupMember = new GroupMember([ + 'group_id' => $this->parentGroup->id, + 'member_id' => 99999, // Non-existent member group ID + 'member_type' => Group::class, + ]); + + // Trigger the created event + $this->observer->created($groupMember); + + // Assert that no GroupMembershipChanged event was dispatched + Event::assertNotDispatched(GroupMembershipChanged::class); + } + + /** + * Test that multiple events can be dispatched for different actions + */ + public function test_can_dispatch_multiple_events_for_different_actions() + { + // Create a group member relationship + $groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger multiple events (excluding restored since soft deletes are disabled) + $this->observer->created($groupMember); + $this->observer->updated($groupMember); + $this->observer->deleted($groupMember); + + // Assert that all events were dispatched (3 events: created, updated, deleted) + Event::assertDispatched(GroupMembershipChanged::class, 3); + + // Check specific events + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getAction() === 'added'; + }); + + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getAction() === 'updated'; + }); + + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getAction() === 'removed'; + }); + } +} diff --git a/tests/unit/ProcessMaker/Repositories/PermissionRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/PermissionRepositoryTest.php new file mode 100644 index 0000000000..a5fd5b97ca --- /dev/null +++ b/tests/unit/ProcessMaker/Repositories/PermissionRepositoryTest.php @@ -0,0 +1,305 @@ +repository = new PermissionRepository(); + + // Create test permissions + $this->permission1 = Permission::factory()->create(['name' => 'permission-1']); + $this->permission2 = Permission::factory()->create(['name' => 'permission-2']); + $this->permission3 = Permission::factory()->create(['name' => 'permission-3']); + + // Create test groups + $this->group1 = Group::factory()->create(['name' => 'Group 1']); + $this->group2 = Group::factory()->create(['name' => 'Group 2']); + $this->group3 = Group::factory()->create(['name' => 'Group 3']); + + // Create test user + $this->user = User::factory()->create(['username' => 'testuser']); + } + + /** + * Test that user permissions include direct permissions + */ + public function test_user_permissions_include_direct_permissions() + { + // Add direct permission to user + $this->user->permissions()->attach($this->permission1->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that direct permission is included + $this->assertContains('permission-1', $permissions); + } + + /** + * Test that user permissions include group permissions + */ + public function test_user_permissions_include_group_permissions() + { + // Add user to group + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + // Add permission to group + $this->group1->permissions()->attach($this->permission1->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that group permission is included + $this->assertContains('permission-1', $permissions); + } + + /** + * Test hierarchical inheritance: User → Group1 → Group2 + */ + public function test_hierarchical_inheritance_two_levels() + { + // Set up hierarchy: User → Group1 → Group2 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that both permissions are inherited + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + } + + /** + * Test hierarchical inheritance: User → Group1 → Group2 → Group3 + */ + public function test_hierarchical_inheritance_three_levels() + { + // Set up hierarchy: User → Group1 → Group2 → Group3 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group3->id, + 'member_id' => $this->group2->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + $this->group3->permissions()->attach($this->permission3->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that all permissions are inherited + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + $this->assertContains('permission-3', $permissions); + } + + /** + * Test that userHasPermission works with hierarchical inheritance + */ + public function test_user_has_permission_with_hierarchical_inheritance() + { + // Set up hierarchy: User → Group1 → Group2 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + // Add permission to Group2 (inherited through Group1) + $this->group2->permissions()->attach($this->permission1->id); + + // Test that user has the inherited permission + $this->assertTrue($this->repository->userHasPermission($this->user->id, 'permission-1')); + $this->assertFalse($this->repository->userHasPermission($this->user->id, 'non-existent-permission')); + } + + /** + * Test that getNestedGroupPermissions includes nested group permissions + */ + public function test_get_group_permissions_includes_nested_permissions() + { + // Set up hierarchy: Group1 → Group2 → Group3 + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group3->id, + 'member_id' => $this->group2->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + $this->group3->permissions()->attach($this->permission3->id); + + // Get Group1 permissions (should include nested) + $permissions = $this->repository->getNestedGroupPermissions($this->group1->id); + + // Assert that all permissions are included + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + $this->assertContains('permission-3', $permissions); + } + + /** + * Test that permissions are not duplicated in inheritance + */ + public function test_permissions_are_not_duplicated_in_inheritance() + { + // Set up hierarchy: User → Group1 → Group2 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + // Add the same permission to both groups + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission1->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that permission appears only once + $this->assertCount(1, array_filter($permissions, fn ($p) => $p === 'permission-1')); + } + + /** + * Test that complex hierarchies work correctly + */ + public function test_complex_hierarchies_work_correctly() + { + // Create additional groups for complex hierarchy + $group4 = Group::factory()->create(['name' => 'Group 4']); + $group5 = Group::factory()->create(['name' => 'Group 5']); + + // Set up complex hierarchy: User → Group1 → Group2 → Group3 + // ↓ + // Group4 → Group5 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $group4->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group3->id, + 'member_id' => $this->group2->id, + 'member_type' => Group::class, + ]); + + $group4->groupMembersFromMemberable()->create([ + 'group_id' => $group5->id, + 'member_id' => $group4->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + $this->group3->permissions()->attach($this->permission3->id); + $group4->permissions()->attach($this->permission1->id); // Same as group1 + $group5->permissions()->attach($this->permission2->id); // Same as group2 + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that all unique permissions are inherited + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + $this->assertContains('permission-3', $permissions); + + // Assert that permissions are not duplicated + $this->assertCount(3, array_unique($permissions)); + } +} diff --git a/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php b/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php new file mode 100644 index 0000000000..46e83aa3af --- /dev/null +++ b/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php @@ -0,0 +1,280 @@ +cacheService = new PermissionCacheService(); + + // Test data + $this->userId = 123; + $this->groupId = 456; + $this->userPermissions = ['permission-1', 'permission-2', 'permission-3']; + $this->groupPermissions = ['group-permission-1', 'group-permission-2']; + + // Clear cache + Cache::flush(); + } + + /** + * Test that cacheUserPermissions stores user permissions correctly + */ + public function test_cache_user_permissions_stores_correctly() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Verify cache was stored + $cachedPermissions = Cache::get("user_permissions:{$this->userId}"); + $this->assertNotNull($cachedPermissions); + $this->assertEquals($this->userPermissions, $cachedPermissions); + } + + /** + * Test that cacheGroupPermissions stores group permissions correctly + */ + public function test_cache_group_permissions_stores_correctly() + { + // Cache group permissions + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Verify cache was stored + $cachedPermissions = Cache::get("group_permissions:{$this->groupId}"); + $this->assertNotNull($cachedPermissions); + $this->assertEquals($this->groupPermissions, $cachedPermissions); + } + + /** + * Test that getUserPermissions retrieves cached user permissions + */ + public function test_get_user_permissions_retrieves_cached_permissions() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Retrieve cached permissions + $retrievedPermissions = $this->cacheService->getUserPermissions($this->userId); + + // Verify permissions were retrieved correctly + $this->assertEquals($this->userPermissions, $retrievedPermissions); + } + + /** + * Test that getGroupPermissions retrieves cached group permissions + */ + public function test_get_group_permissions_retrieves_cached_permissions() + { + // Cache group permissions + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Retrieve cached permissions + $retrievedPermissions = $this->cacheService->getGroupPermissions($this->groupId); + + // Verify permissions were retrieved correctly + $this->assertEquals($this->groupPermissions, $retrievedPermissions); + } + + /** + * Test that getUserPermissions returns null when cache is empty + */ + public function test_get_user_permissions_returns_null_when_cache_empty() + { + // Try to retrieve permissions without caching + $retrievedPermissions = $this->cacheService->getUserPermissions($this->userId); + + // Verify null is returned + $this->assertNull($retrievedPermissions); + } + + /** + * Test that getGroupPermissions returns null when cache is empty + */ + public function test_get_group_permissions_returns_null_when_cache_empty() + { + // Try to retrieve permissions without caching + $retrievedPermissions = $this->cacheService->getGroupPermissions($this->groupId); + + // Verify null is returned + $this->assertNull($retrievedPermissions); + } + + /** + * Test that invalidateUserPermissions clears user cache correctly + */ + public function test_invalidate_user_permissions_clears_cache_correctly() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + + // Invalidate cache + $this->cacheService->invalidateUserPermissions($this->userId); + + // Verify cache was cleared + $this->assertNull(Cache::get("user_permissions:{$this->userId}")); + } + + /** + * Test that invalidateGroupPermissions clears group cache correctly + */ + public function test_invalidate_group_permissions_clears_cache_correctly() + { + // Cache group permissions + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Verify cache exists + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + + // Invalidate cache + $this->cacheService->invalidateGroupPermissions($this->groupId); + + // Verify cache was cleared + $this->assertNull(Cache::get("group_permissions:{$this->groupId}")); + } + + /** + * Test that clearAll clears all permission caches + */ + public function test_clear_all_clears_all_permission_caches() + { + // Cache both user and group permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Verify both caches exist + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + + // Clear all caches + $this->cacheService->clearAll(); + + // Verify both caches were cleared + $this->assertNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNull(Cache::get("group_permissions:{$this->groupId}")); + } + + /** + * Test that cache keys are generated correctly + */ + public function test_cache_keys_are_generated_correctly() + { + // Test that cache keys follow the expected pattern + $userKey = "user_permissions:{$this->userId}"; + $groupKey = "group_permissions:{$this->groupId}"; + + $this->assertEquals("user_permissions:{$this->userId}", $userKey); + $this->assertEquals("group_permissions:{$this->groupId}", $groupKey); + } + + /** + * Test that cache TTL is respected + */ + public function test_cache_ttl_is_respected() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + + // Note: We can't directly test TTL in unit tests, but we can verify the cache exists + // The TTL is set in the service configuration (1 hour for users, 2 hours for groups) + $this->assertTrue(Cache::has("user_permissions:{$this->userId}")); + } + + /** + * Test that multiple users can have separate caches + */ + public function test_multiple_users_can_have_separate_caches() + { + $userId2 = 789; + $userPermissions2 = ['permission-4', 'permission-5']; + + // Cache permissions for both users + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->cacheUserPermissions($userId2, $userPermissions2); + + // Verify both caches exist separately + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("user_permissions:{$userId2}")); + + // Verify caches contain different data + $cachedPermissions1 = Cache::get("user_permissions:{$this->userId}"); + $cachedPermissions2 = Cache::get("user_permissions:{$userId2}"); + + $this->assertEquals($this->userPermissions, $cachedPermissions1); + $this->assertEquals($userPermissions2, $cachedPermissions2); + $this->assertNotEquals($cachedPermissions1, $cachedPermissions2); + } + + /** + * Test that multiple groups can have separate caches + */ + public function test_multiple_groups_can_have_separate_caches() + { + $groupId2 = 789; + $groupPermissions2 = ['group-permission-3', 'group-permission-4']; + + // Cache permissions for both groups + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + $this->cacheService->cacheGroupPermissions($groupId2, $groupPermissions2); + + // Verify both caches exist separately + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + $this->assertNotNull(Cache::get("group_permissions:{$groupId2}")); + + // Verify caches contain different data + $cachedPermissions1 = Cache::get("group_permissions:{$this->groupId}"); + $cachedPermissions2 = Cache::get("group_permissions:{$groupId2}"); + + $this->assertEquals($this->groupPermissions, $cachedPermissions1); + $this->assertEquals($groupPermissions2, $cachedPermissions2); + $this->assertNotEquals($cachedPermissions1, $cachedPermissions2); + } + + /** + * Test that cache service handles empty permission arrays gracefully + */ + public function test_handles_empty_permission_arrays_gracefully() + { + // Cache empty permissions + $this->cacheService->cacheUserPermissions($this->userId, []); + $this->cacheService->cacheGroupPermissions($this->groupId, []); + + // Verify caches were stored + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + + // Verify retrieved permissions are empty arrays + $retrievedUserPermissions = $this->cacheService->getUserPermissions($this->userId); + $retrievedGroupPermissions = $this->cacheService->getGroupPermissions($this->groupId); + + $this->assertIsArray($retrievedUserPermissions); + $this->assertIsArray($retrievedGroupPermissions); + $this->assertEmpty($retrievedUserPermissions); + $this->assertEmpty($retrievedGroupPermissions); + } +} diff --git a/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php b/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php new file mode 100644 index 0000000000..f62c4eb626 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php @@ -0,0 +1,260 @@ +serviceManager = app(PermissionServiceManager::class); + + // Create test data + $this->permission = Permission::factory()->create(['name' => 'test-permission']); + $this->group = Group::factory()->create(['name' => 'Test Group']); + $this->user = User::factory()->create(['username' => 'testuser']); + + // Clear cache + Cache::flush(); + } + + /** + * Test that getUserPermissions returns cached permissions when available + */ + public function test_get_user_permissions_returns_cached_permissions() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Warm up cache + $this->serviceManager->warmUpUserCache($this->user->id); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Get permissions (should come from cache) + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + + // Assert that permissions are returned + $this->assertContains('test-permission', $permissions); + } + + /** + * Test that getUserPermissions fetches from repository when cache is empty + */ + public function test_get_user_permissions_fetches_from_repository_when_cache_empty() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Verify cache is empty + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + + // Get permissions (should fetch from repository) + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + + // Assert that permissions are returned + $this->assertContains('test-permission', $permissions); + + // Verify cache was populated + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + } + + /** + * Test that userHasPermission works correctly + */ + public function test_user_has_permission_works_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission + $this->assertTrue($this->serviceManager->userHasPermission($this->user->id, 'test-permission')); + $this->assertFalse($this->serviceManager->userHasPermission($this->user->id, 'non-existent-permission')); + } + + /** + * Test that warmUpUserCache populates cache correctly + */ + public function test_warm_up_user_cache_populates_cache_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Verify cache is empty initially + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + + // Warm up cache + $this->serviceManager->warmUpUserCache($this->user->id); + + // Verify cache was populated + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Verify cache contains correct data + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertContains('test-permission', $cachedPermissions); + } + + /** + * Test that invalidateUserCache clears cache correctly + */ + public function test_invalidate_user_cache_clears_cache_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Warm up cache + $this->serviceManager->warmUpUserCache($this->user->id); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Invalidate cache + $this->serviceManager->invalidateUserCache($this->user->id); + + // Verify cache was cleared + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + } + + /** + * Test that hierarchical inheritance works through the service manager + */ + public function test_hierarchical_inheritance_works_through_service_manager() + { + // Create nested groups + $parentGroup = Group::factory()->create(['name' => 'Parent Group']); + $childGroup = Group::factory()->create(['name' => 'Child Group']); + + // Set up hierarchy: User → ChildGroup → ParentGroup + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $childGroup->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $childGroup->groupMembersFromMemberable()->create([ + 'group_id' => $parentGroup->id, + 'member_id' => $childGroup->id, + 'member_type' => Group::class, + ]); + + // Add permission to parent group + $parentGroup->permissions()->attach($this->permission->id); + + // Test that user has the inherited permission + $this->assertTrue($this->serviceManager->userHasPermission($this->user->id, 'test-permission')); + + // Get all user permissions + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + $this->assertContains('test-permission', $permissions); + } + + /** + * Test that cache is properly managed for multiple users + */ + public function test_cache_is_properly_managed_for_multiple_users() + { + // Create second user + $user2 = User::factory()->create(['username' => 'testuser2']); + + // Add both users to the same group + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $user2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $user2->id, + 'member_type' => User::class, + ]); + + $this->group->permissions()->attach($this->permission->id); + + // Warm up cache for both users + $this->serviceManager->warmUpUserCache($this->user->id); + $this->serviceManager->warmUpUserCache($user2->id); + + // Verify both caches exist + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + $this->assertNotNull(Cache::get("user_permissions:{$user2->id}")); + + // Invalidate cache for first user only + $this->serviceManager->invalidateUserCache($this->user->id); + + // Verify first user cache was cleared, second user cache remains + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + $this->assertNotNull(Cache::get("user_permissions:{$user2->id}")); + } + + /** + * Test that the service manager handles users with no permissions gracefully + */ + public function test_handles_users_with_no_permissions_gracefully() + { + // User has no groups or permissions + + // Test that userHasPermission returns false + $this->assertFalse($this->serviceManager->userHasPermission($this->user->id, 'any-permission')); + + // Test that getUserPermissions returns empty array + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + $this->assertIsArray($permissions); + $this->assertEmpty($permissions); + + // Test that cache can still be warmed up + $this->serviceManager->warmUpUserCache($this->user->id); + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Verify cached permissions are empty + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertIsArray($cachedPermissions); + $this->assertEmpty($cachedPermissions); + } +} diff --git a/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php b/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php new file mode 100644 index 0000000000..c32432b523 --- /dev/null +++ b/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php @@ -0,0 +1,240 @@ +permission = Permission::factory()->create(['name' => 'test-permission']); + $this->group = Group::factory()->create(['name' => 'Test Group']); + $this->user = User::factory()->create(['username' => 'testuser']); + } + + /** + * Test that hasPermission delegates to PermissionServiceManager + */ + public function test_has_permission_delegates_to_permission_service_manager() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertFalse($this->user->hasPermission('non-existent-permission')); + } + + /** + * Test that loadPermissions works correctly + */ + public function test_load_permissions_works_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Load permissions + $this->user->loadPermissions(); + + // Test that permissions were loaded + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that invalidatePermissionCache works correctly + */ + public function test_invalidate_permission_cache_works_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission initially + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Invalidate cache + $this->user->invalidatePermissionCache(); + + // Test that user still has the permission after cache invalidation + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that cache invalidation actually works when permissions change + */ + public function test_cache_invalidation_works_when_permissions_change() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission initially + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Remove the permission from the group + $this->group->permissions()->detach($this->permission->id); + + // Test that user still has the permission (due to caching) + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Invalidate cache` + $this->user->invalidatePermissionCache(); + + // Test that user no longer has the permission after cache invalidation + $this->assertFalse($this->user->hasPermission('test-permission')); + } + + /** + * Test that hierarchical inheritance works through the trait + */ + public function test_hierarchical_inheritance_works_through_trait() + { + // Create nested groups + $parentGroup = Group::factory()->create(['name' => 'Parent Group']); + $childGroup = Group::factory()->create(['name' => 'Child Group']); + + // Set up hierarchy: User → ChildGroup → ParentGroup + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $childGroup->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $childGroup->groupMembersFromMemberable()->create([ + 'group_id' => $parentGroup->id, + 'member_id' => $childGroup->id, + 'member_type' => Group::class, + ]); + + // Add permission to parent group + $parentGroup->permissions()->attach($this->permission->id); + + // Test that user has the inherited permission through the trait + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that the trait handles users with no permissions gracefully + */ + public function test_handles_users_with_no_permissions_gracefully() + { + // User has no groups or permissions + + // Test that hasPermission returns false + $this->assertFalse($this->user->hasPermission('any-permission')); + + // Test that loadPermissions doesn't throw errors + $this->user->loadPermissions(); + + // Test that invalidatePermissionCache doesn't throw errors + $this->user->invalidatePermissionCache(); + } + + /** + * Test that the trait works with multiple permissions + */ + public function test_works_with_multiple_permissions() + { + // Create additional permissions + $permission2 = Permission::factory()->create(['name' => 'permission-2']); + $permission3 = Permission::factory()->create(['name' => 'permission-3']); + + // Add user to group with multiple permissions + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group->permissions()->attach([ + $this->permission->id, + $permission2->id, + $permission3->id, + ]); + + // Test that user has all permissions + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertTrue($this->user->hasPermission('permission-2')); + $this->assertTrue($this->user->hasPermission('permission-3')); + $this->assertFalse($this->user->hasPermission('non-existent-permission')); + } + + /** + * Test that the trait works with direct user permissions + */ + public function test_works_with_direct_user_permissions() + { + // Add permission directly to user + $this->user->permissions()->attach($this->permission->id); + + // Test that user has the direct permission + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Test that loadPermissions still works + $this->user->loadPermissions(); + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that the trait works with both direct and group permissions + */ + public function test_works_with_both_direct_and_group_permissions() + { + // Create additional permission + $permission2 = Permission::factory()->create(['name' => 'permission-2']); + + // Add permission directly to user + $this->user->permissions()->attach($this->permission->id); + + // Add user to group with different permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($permission2->id); + + // Test that user has both permissions + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertTrue($this->user->hasPermission('permission-2')); + + // Test that loadPermissions works + $this->user->loadPermissions(); + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertTrue($this->user->hasPermission('permission-2')); + } +}