From 2b6156fa71c3c87443a4d21876bdf24b15ea8fc1 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 15 Oct 2026 16:37:32 +0200 Subject: [PATCH] feat: require the pollora/roles package Roles and capabilities live in pollora/roles: the definition builder, the registry, the compiler, the WordPress adapters and the discovery, with their tests. The framework keeps the Laravel side: provider, middleware, @role directive, doctor check and commands. --- CHANGELOG.md | 1 + composer.json | 1 + .../Services/CapabilityOwnerReader.php | 70 ----- .../Services/RoleDefinitionBuilder.php | 204 ------------- .../Application/Services/RoleReference.php | 68 ----- .../Application/Services/RoleRegistry.php | 268 ------------------ .../Domain/Contracts/RoleStoreInterface.php | 43 --- .../Domain/Contracts/RoleUsageInterface.php | 29 -- .../InvalidRoleDefinitionException.php | 21 -- src/Role/Domain/Models/RoleChanges.php | 38 --- src/Role/Domain/Models/RoleDefinition.php | 28 -- src/Role/Domain/Models/RoleModification.php | 25 -- .../Domain/Services/PostTypeCapabilityMap.php | 33 --- src/Role/Domain/Services/RoleCompiler.php | 228 --------------- .../Domain/Services/StoredEntryClassifier.php | 42 --- .../Adapters/WordPressRoleInjector.php | 133 --------- .../Adapters/WordPressRoleLabelTranslator.php | 33 --- .../Adapters/WordPressRoleStore.php | 61 ---- .../Adapters/WordPressRoleUsage.php | 77 ----- .../Infrastructure/Services/RoleDiscovery.php | 113 -------- tests/Unit/Role/PostTypeCapabilityMapTest.php | 22 -- tests/Unit/Role/RoleCompilerTest.php | 152 ---------- tests/Unit/Role/RoleDefinitionBuilderTest.php | 105 ------- tests/Unit/Role/RoleDiscoveryTest.php | 97 ------- tests/Unit/Role/RoleReferenceTest.php | 29 -- tests/Unit/Role/RoleRegistryTest.php | 124 -------- tests/Unit/Role/WordPressRoleInjectorTest.php | 115 -------- .../Role/WordPressRoleLabelTranslatorTest.php | 29 -- tests/Unit/Role/WordPressRoleStoreTest.php | 55 ---- tests/Unit/Role/WordPressRoleUsageTest.php | 57 ---- 30 files changed, 2 insertions(+), 2299 deletions(-) delete mode 100644 src/Role/Application/Services/CapabilityOwnerReader.php delete mode 100644 src/Role/Application/Services/RoleDefinitionBuilder.php delete mode 100644 src/Role/Application/Services/RoleReference.php delete mode 100644 src/Role/Application/Services/RoleRegistry.php delete mode 100644 src/Role/Domain/Contracts/RoleStoreInterface.php delete mode 100644 src/Role/Domain/Contracts/RoleUsageInterface.php delete mode 100644 src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php delete mode 100644 src/Role/Domain/Models/RoleChanges.php delete mode 100644 src/Role/Domain/Models/RoleDefinition.php delete mode 100644 src/Role/Domain/Models/RoleModification.php delete mode 100644 src/Role/Domain/Services/PostTypeCapabilityMap.php delete mode 100644 src/Role/Domain/Services/RoleCompiler.php delete mode 100644 src/Role/Domain/Services/StoredEntryClassifier.php delete mode 100644 src/Role/Infrastructure/Adapters/WordPressRoleInjector.php delete mode 100644 src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php delete mode 100644 src/Role/Infrastructure/Adapters/WordPressRoleStore.php delete mode 100644 src/Role/Infrastructure/Adapters/WordPressRoleUsage.php delete mode 100644 src/Role/Infrastructure/Services/RoleDiscovery.php delete mode 100644 tests/Unit/Role/PostTypeCapabilityMapTest.php delete mode 100644 tests/Unit/Role/RoleCompilerTest.php delete mode 100644 tests/Unit/Role/RoleDefinitionBuilderTest.php delete mode 100644 tests/Unit/Role/RoleDiscoveryTest.php delete mode 100644 tests/Unit/Role/RoleReferenceTest.php delete mode 100644 tests/Unit/Role/RoleRegistryTest.php delete mode 100644 tests/Unit/Role/WordPressRoleInjectorTest.php delete mode 100644 tests/Unit/Role/WordPressRoleLabelTranslatorTest.php delete mode 100644 tests/Unit/Role/WordPressRoleStoreTest.php delete mode 100644 tests/Unit/Role/WordPressRoleUsageTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 275b29fa..c5a6aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.0...develop) ### Added +- Roles and capabilities live in their own package, [`pollora/roles`](https://github.com/Pollora/roles), which the framework requires: `RoleDefinitionBuilder`, `RoleRegistry`, `RoleCompiler`, `PostTypeCapabilityMap`, the capability readers, the WordPress adapters (stored roles, `WP_Roles` injection, label translation, role usage) and `RoleDiscovery`, with their tests. Class names do not change. The framework keeps the Laravel side: `RoleServiceProvider`, the `role` middleware, the `@role` directive, the `HasRoles` bridge (`$user->can()`, `@can`), the `pollora:roles:*` commands and the `pollora:doctor` check - Typed meta lives in its own package, [`pollora/meta`](https://github.com/Pollora/meta), which the framework requires: `MetaAccessor` (`of()`, `schemas()`, `schemaFor()`, `extend()`), the fluent `MetaRecord`, the schema builder and repository, `MetaAuditor`, `MetaUiDrivers`, the value caster, the WordPress adapters, `MetaDiscovery` and `MetaUiDriverConformance`, with their tests. Class names do not change. The framework keeps what needs Laravel: `MetaServiceProvider`, the `Meta` facade and `HasTypedMeta`, the Laravel validator (`LaravelMetaValidator`), the `pollora:meta:*` commands and the `pollora:doctor` check - The asset classes live in their own package, [`pollora/assets`](https://github.com/Pollora/assets), which the framework requires: `AssetManager`, `AssetContainer`, `AssetEnqueuer` (`Asset::add()->container()->useVite()`), `AssetFile`, the record and the wp-admin notice of a missing build. Class names do not change. The package also resolves Vite builds without Laravel, for a classic theme, a plugin or a Bedrock site. The framework keeps what needs Laravel: the service provider, the `Asset` facade, `ViteManager` (Laravel's Vite, where `ASSET_URL`, a CDN and the application URL keep applying), `RootAssetManager` and the Laravel implementations of the package's three contracts (`ViteManagerFactory`, `RuntimeContext`, `PublicPath`) diff --git a/composer.json b/composer.json index 6bf48558..10e60937 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "pollora/meta": "^0.1", "pollora/hook": "^2.0.1", "pollora/option": "^1.1", + "pollora/roles": "^0.1", "pollora/schedule": "^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", diff --git a/src/Role/Application/Services/CapabilityOwnerReader.php b/src/Role/Application/Services/CapabilityOwnerReader.php deleted file mode 100644 index 54b30528..00000000 --- a/src/Role/Application/Services/CapabilityOwnerReader.php +++ /dev/null @@ -1,70 +0,0 @@ -}|null Null when the class is not a post type - */ - public function postType(string $class): ?array - { - if (! class_exists($class)) { - return null; - } - - $reflection = new ReflectionClass($class); - $postType = $reflection->getAttributes(PostType::class)[0] ?? null; - - if ($postType === null) { - return null; - } - - $capabilityType = $reflection->getAttributes(CapabilityType::class)[0] ?? null; - $capabilities = $reflection->getAttributes(PostTypeCapabilities::class)[0] ?? null; - - return [ - 'slug' => $postType->newInstance()->resolveSlug($class), - 'capabilityType' => $capabilityType?->newInstance()->value, - 'overrides' => $capabilities?->newInstance()->value ?? [], - ]; - } - - /** - * @return array{slug: string, capabilities: list}|null Null when the class is not a taxonomy - */ - public function taxonomy(string $class): ?array - { - if (! class_exists($class)) { - return null; - } - - $reflection = new ReflectionClass($class); - $taxonomy = $reflection->getAttributes(Taxonomy::class)[0] ?? null; - - if ($taxonomy === null) { - return null; - } - - $map = ($reflection->getAttributes(TaxonomyCapabilities::class)[0] ?? null)?->newInstance()->value ?? []; - - return [ - 'slug' => $taxonomy->newInstance()->resolveSlug($class), - 'capabilities' => array_values(array_intersect_key($map, array_flip(self::TERM_CAPABILITIES))), - ]; - } -} diff --git a/src/Role/Application/Services/RoleDefinitionBuilder.php b/src/Role/Application/Services/RoleDefinitionBuilder.php deleted file mode 100644 index f70df2df..00000000 --- a/src/Role/Application/Services/RoleDefinitionBuilder.php +++ /dev/null @@ -1,204 +0,0 @@ - $superRoles Roles that may not be inherited from - */ - public function __construct( - private CapabilityOwnerReader $owners, - private array $superRoles = ['administrator'], - ) {} - - /** - * @param class-string $class - * - * @throws InvalidRoleDefinitionException - */ - public function build(string $class): RoleDefinition|RoleModification - { - $reflection = new ReflectionClass($class); - $role = ($reflection->getAttributes(Role::class)[0] ?? null)?->newInstance(); - $modifyRole = ($reflection->getAttributes(ModifyRole::class)[0] ?? null)?->newInstance(); - - if ($role instanceof Role && $modifyRole instanceof ModifyRole) { - throw InvalidRoleDefinitionException::forClass($class, 'a class declares a role (#[Role]) or modifies one (#[ModifyRole]), not both.'); - } - - if (! $role instanceof Role && ! $modifyRole instanceof ModifyRole) { - throw InvalidRoleDefinitionException::forClass($class, 'it carries neither #[Role] nor #[ModifyRole].'); - } - - $changes = $this->changes($reflection, $role->allowSensitive ?? $modifyRole->allowSensitive); - - if ($modifyRole instanceof ModifyRole) { - return new RoleModification($modifyRole->slug, $changes, $class); - } - - if (in_array($role->slug, self::CORE_ROLES, true)) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('"%s" is a core role; adjust it with #[ModifyRole(\'%s\')] instead of redeclaring it.', $role->slug, $role->slug)); - } - - $inherits = $role->inherits === null ? null : $this->resolveRoleSlug($class, $role->inherits); - - if ($inherits !== null && in_array($inherits, $this->superRoles, true)) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('a role cannot inherit from the super role "%s".', $inherits)); - } - - return new RoleDefinition( - slug: $role->slug, - label: $role->label ?? Naming::headline(Naming::classBasename($class)), - inherits: $inherits, - changes: $changes, - declaringClass: $class, - textDomain: $role->textDomain, - ); - } - - /** - * @param ReflectionClass $reflection - */ - private function changes(ReflectionClass $reflection, bool $allowSensitive): RoleChanges - { - $class = $reflection->getName(); - $grants = $this->capabilities($reflection, Grants::class); - $removals = $this->capabilities($reflection, Without::class); - - $both = array_intersect($grants, $removals); - if ($both !== []) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('"%s" is both granted and removed.', implode('", "', $both))); - } - - $sensitive = array_intersect($grants, self::SENSITIVE_CAPABILITIES); - if ($sensitive !== [] && ! $allowSensitive) { - throw InvalidRoleDefinitionException::forClass($class, sprintf( - 'granting "%s" lets a user take over the site; confirm it with allowSensitive: true.', - implode('", "', $sensitive) - )); - } - - $postTypeGrants = []; - foreach ($reflection->getAttributes(GrantsPostType::class) as $attribute) { - $grant = $attribute->newInstance(); - $this->assertPostTypeHasCapabilities($class, $grant->postType); - $postTypeGrants[] = ['postType' => $grant->postType, 'access' => $grant->access]; - } - - $taxonomyGrants = []; - foreach ($reflection->getAttributes(GrantsTaxonomy::class) as $attribute) { - $taxonomy = $attribute->newInstance()->taxonomy; - $this->assertTaxonomyHasCapabilities($class, $taxonomy); - $taxonomyGrants[] = $taxonomy; - } - - return new RoleChanges(array_values(array_unique($grants)), array_values(array_unique($removals)), $postTypeGrants, $taxonomyGrants); - } - - /** - * @param ReflectionClass $reflection - * @param class-string $attributeClass - * @return list - */ - private function capabilities(ReflectionClass $reflection, string $attributeClass): array - { - $capabilities = []; - - foreach ($reflection->getAttributes($attributeClass) as $attribute) { - foreach ($attribute->newInstance()->capabilities as $capability) { - $capabilities[] = $capability instanceof BackedEnum ? (string) $capability->value : $capability; - } - } - - return $capabilities; - } - - private function resolveRoleSlug(string $class, string $reference): string - { - if (! class_exists($reference)) { - return $reference; - } - - $role = ((new ReflectionClass($reference))->getAttributes(Role::class)[0] ?? null)?->newInstance(); - - return $role instanceof Role ? $role->slug : throw InvalidRoleDefinitionException::forClass($class, sprintf('it inherits from %s, which is not a #[Role].', $reference)); - } - - private function assertPostTypeHasCapabilities(string $class, string $postType): void - { - if (! class_exists($postType)) { - return; - } - - $owner = $this->owners->postType($postType); - - if ($owner === null) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('#[GrantsPostType] names %s, which is not a #[PostType].', $postType)); - } - - if ($owner['capabilityType'] === null) { - throw InvalidRoleDefinitionException::forClass($class, sprintf( - 'the post type "%s" shares the capabilities of posts; give %s its own with #[CapabilityType(\'%s\')] and #[MapMetaCap].', - $owner['slug'], - $postType, - str_replace('-', '_', $owner['slug']) - )); - } - } - - private function assertTaxonomyHasCapabilities(string $class, string $taxonomy): void - { - if (! class_exists($taxonomy)) { - return; - } - - $owner = $this->owners->taxonomy($taxonomy); - - if ($owner === null) { - throw InvalidRoleDefinitionException::forClass($class, sprintf('#[GrantsTaxonomy] names %s, which is not a #[Taxonomy].', $taxonomy)); - } - - if ($owner['capabilities'] === []) { - throw InvalidRoleDefinitionException::forClass($class, sprintf( - 'the taxonomy "%s" shares the capabilities of categories; give %s its own with #[Capabilities([...])] (manage_terms, edit_terms, delete_terms, assign_terms).', - $owner['slug'], - $taxonomy - )); - } - } -} diff --git a/src/Role/Application/Services/RoleReference.php b/src/Role/Application/Services/RoleReference.php deleted file mode 100644 index 369f085f..00000000 --- a/src/Role/Application/Services/RoleReference.php +++ /dev/null @@ -1,68 +0,0 @@ - */ - private static array $slugs = []; - - /** - * @throws InvalidArgumentException When a class is given that carries neither #[Role] nor #[ModifyRole] - */ - public static function slug(string $role): string - { - if (! class_exists($role)) { - return $role; - } - - return self::$slugs[$role] ??= self::slugOfClass($role); - } - - /** - * Whether one of the roles a user has is among the given ones, ignoring case - * as `@role` always has. - * - * @param array $userRoles The slugs the user has - * @param array $roles Slugs or role classes - */ - public static function matchesAny(array $userRoles, array $roles): bool - { - $userRoles = array_map(strtolower(...), $userRoles); - - foreach ($roles as $role) { - if (in_array(strtolower(self::slug($role)), $userRoles, true)) { - return true; - } - } - - return false; - } - - /** - * @param class-string $class - */ - private static function slugOfClass(string $class): string - { - $reflection = new ReflectionClass($class); - $attribute = ($reflection->getAttributes(Role::class)[0] ?? $reflection->getAttributes(ModifyRole::class)[0] ?? null)?->newInstance(); - - if (! $attribute instanceof Role && ! $attribute instanceof ModifyRole) { - throw new InvalidArgumentException(sprintf('%s does not name a role: it carries neither #[Role] nor #[ModifyRole].', $class)); - } - - return $attribute->slug; - } -} diff --git a/src/Role/Application/Services/RoleRegistry.php b/src/Role/Application/Services/RoleRegistry.php deleted file mode 100644 index 4032385f..00000000 --- a/src/Role/Application/Services/RoleRegistry.php +++ /dev/null @@ -1,268 +0,0 @@ - */ - private array $definitions = []; - - /** @var list */ - private array $modifications = []; - - /** @var array}> Post types with their own capabilities, by class and by slug */ - private array $postTypes = []; - - /** @var array}> Taxonomies with their own capabilities, by class and by slug */ - private array $taxonomies = []; - - /** @var list */ - private array $capabilitySets = []; - - public function __construct( - private readonly CapabilityOwnerReader $owners, - private readonly PostTypeCapabilityMap $postTypeCapabilities, - ) {} - - /** - * @throws InvalidRoleDefinitionException When the slug is taken or the inheritance loops - */ - public function addRole(RoleDefinition $definition): void - { - $existing = $this->definitions[$definition->slug] ?? null; - - if ($existing instanceof RoleDefinition && $existing->declaringClass !== $definition->declaringClass) { - throw InvalidRoleDefinitionException::forClass($definition->declaringClass, sprintf('the role "%s" is already declared by %s.', $definition->slug, $existing->declaringClass)); - } - - $chain = [$definition->slug]; - $parent = $definition->inherits; - - while ($parent !== null) { - if (in_array($parent, $chain, true)) { - throw InvalidRoleDefinitionException::forClass($definition->declaringClass, sprintf('inheritance loops: %s.', implode(' → ', [...$chain, $parent]))); - } - - if (! isset($this->definitions[$parent])) { - break; - } - - $chain[] = $parent; - $parent = $this->definitions[$parent]->inherits; - } - - $this->definitions[$definition->slug] = $definition; - } - - /** - * @throws InvalidRoleDefinitionException When another class removes what this one grants, or the reverse - */ - public function addModification(RoleModification $modification): void - { - foreach ($this->modifications as $existing) { - if ($existing->slug !== $modification->slug || $existing->declaringClass === $modification->declaringClass) { - continue; - } - - $conflicts = [ - ...array_intersect($existing->changes->grants, $modification->changes->removals), - ...array_intersect($existing->changes->removals, $modification->changes->grants), - ]; - - if ($conflicts !== []) { - throw InvalidRoleDefinitionException::forClass($modification->declaringClass, sprintf( - 'it contradicts %s on the role "%s": "%s" is both granted and removed.', - $existing->declaringClass, - $modification->slug, - implode('", "', array_unique($conflicts)) - )); - } - } - - $this->modifications = [ - ...array_filter($this->modifications, static fn (RoleModification $existing): bool => $existing->declaringClass !== $modification->declaringClass), - $modification, - ]; - } - - /** - * Records a post type class; only one with its own capability type counts. - */ - public function addPostType(string $class): void - { - $postType = $this->owners->postType($class); - - if ($postType === null || $postType['capabilityType'] === null) { - return; - } - - $this->postTypes[$class] = $this->postTypes[$postType['slug']] = [ - 'slug' => $postType['slug'], - 'capabilityType' => $postType['capabilityType'], - 'overrides' => $postType['overrides'], - ]; - } - - /** - * Records a taxonomy class; only one with its own term capabilities counts. - */ - public function addTaxonomy(string $class): void - { - $taxonomy = $this->owners->taxonomy($class); - - if ($taxonomy === null || $taxonomy['capabilities'] === []) { - return; - } - - $this->taxonomies[$class] = $this->taxonomies[$taxonomy['slug']] = $taxonomy; - } - - /** - * @param list $capabilities The values of a #[CapabilitySet] enum - */ - public function addCapabilitySet(array $capabilities): void - { - $this->capabilitySets = array_values(array_unique([...$this->capabilitySets, ...$capabilities])); - } - - public function isEmpty(): bool - { - return $this->definitions === [] && $this->modifications === [] && $this->postTypes === [] && $this->taxonomies === [] && $this->capabilitySets === []; - } - - /** - * Declared roles, with post type and taxonomy grants resolved into capability names. - * - * @param Closure(string): void|null $warn Receives the grants that could not be resolved - * @return list - */ - public function definitions(?Closure $warn = null): array - { - return array_values(array_map( - fn (RoleDefinition $definition): RoleDefinition => $definition->withChanges($this->resolve($definition->changes, $definition->declaringClass, $warn)), - $this->definitions - )); - } - - /** - * Modifications, with post type and taxonomy grants resolved into capability names. - * - * @param Closure(string): void|null $warn Receives the grants that could not be resolved - * @return list - */ - public function modifications(?Closure $warn = null): array - { - return array_values(array_map( - fn (RoleModification $modification): RoleModification => $modification->withChanges($this->resolve($modification->changes, $modification->declaringClass, $warn)), - $this->modifications - )); - } - - /** - * The text domain of each role label that has one, by label. - * - * @return array - */ - public function labelDomains(): array - { - $domains = []; - - foreach ($this->definitions as $definition) { - if ($definition->textDomain !== null) { - $domains[$definition->label] = $definition->textDomain; - } - } - - return $domains; - } - - /** - * Every capability the project declares, for the super roles. - * - * @return list - */ - public function declaredCapabilities(): array - { - $capabilities = $this->capabilitySets; - - foreach ($this->postTypes as $postType) { - $capabilities = [...$capabilities, ...$this->postTypeCapabilities->capabilities($postType['capabilityType'], Access::Editor, $postType['overrides'])]; - } - - foreach ($this->taxonomies as $taxonomy) { - $capabilities = [...$capabilities, ...$taxonomy['capabilities']]; - } - - return array_values(array_unique($capabilities)); - } - - /** - * @param Closure(string): void|null $warn - */ - private function resolve(RoleChanges $changes, string $declaringClass, ?Closure $warn): RoleChanges - { - $grants = $changes->grants; - - foreach ($changes->postTypeGrants as ['postType' => $reference, 'access' => $access]) { - if (! isset($this->postTypes[$reference]) && class_exists($reference)) { - $this->addPostType($reference); - } - - $postType = $this->postTypes[$reference] ?? null; - - if ($postType === null) { - $this->warnUnresolved($warn, $declaringClass, sprintf('the post type "%s" was not found with its own capabilities', $reference)); - - continue; - } - - $grants = [...$grants, ...$this->postTypeCapabilities->capabilities($postType['capabilityType'], $access, $postType['overrides'])]; - } - - foreach ($changes->taxonomyGrants as $reference) { - if (! isset($this->taxonomies[$reference]) && class_exists($reference)) { - $this->addTaxonomy($reference); - } - - $taxonomy = $this->taxonomies[$reference] ?? null; - - if ($taxonomy === null) { - $this->warnUnresolved($warn, $declaringClass, sprintf('the taxonomy "%s" was not found with its own capabilities', $reference)); - - continue; - } - - $grants = [...$grants, ...$taxonomy['capabilities']]; - } - - return $changes->withResolvedGrants($grants); - } - - /** - * @param Closure(string): void|null $warn - */ - private function warnUnresolved(?Closure $warn, string $declaringClass, string $reason): void - { - if ($warn instanceof Closure) { - $warn(sprintf('%s: %s; its grant is ignored.', $declaringClass, $reason)); - } - } -} diff --git a/src/Role/Domain/Contracts/RoleStoreInterface.php b/src/Role/Domain/Contracts/RoleStoreInterface.php deleted file mode 100644 index 435940ca..00000000 --- a/src/Role/Domain/Contracts/RoleStoreInterface.php +++ /dev/null @@ -1,43 +0,0 @@ -> - */ - public function storedRoles(): array; - - /** - * Replaces the stored roles. - * - * @param array> $roles - */ - public function saveStoredRoles(array $roles): void; - - /** - * The roles WordPress knows that a user carries. - * - * @return list - */ - public function rolesOf(int $userId): array; - - /** - * Removes an entry (a role or a capability) from a user's stored capabilities. - */ - public function removeFromUser(int $userId, string $entry): void; - - /** - * Gives a user a role, keeping the others. - */ - public function addRoleToUser(int $userId, string $role): void; -} diff --git a/src/Role/Domain/Contracts/RoleUsageInterface.php b/src/Role/Domain/Contracts/RoleUsageInterface.php deleted file mode 100644 index 42fa480b..00000000 --- a/src/Role/Domain/Contracts/RoleUsageInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - public function entries(): array; - - /** - * The users carrying an entry, by ID, with their login. - * - * @return array - */ - public function usersWith(string $entry): array; -} diff --git a/src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php b/src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php deleted file mode 100644 index 968881b8..00000000 --- a/src/Role/Domain/Exceptions/InvalidRoleDefinitionException.php +++ /dev/null @@ -1,21 +0,0 @@ - $grants Capability names granted - * @param list $removals Capability names removed - * @param list $postTypeGrants Post types (class or slug) granted at a level - * @param list $taxonomyGrants Taxonomies (class or slug) whose term capabilities are granted - */ - public function __construct( - public array $grants = [], - public array $removals = [], - public array $postTypeGrants = [], - public array $taxonomyGrants = [], - ) {} - - /** - * @param list $grants - */ - public function withResolvedGrants(array $grants): self - { - return new self(array_values(array_unique($grants)), $this->removals); - } -} diff --git a/src/Role/Domain/Models/RoleDefinition.php b/src/Role/Domain/Models/RoleDefinition.php deleted file mode 100644 index 25d38890..00000000 --- a/src/Role/Domain/Models/RoleDefinition.php +++ /dev/null @@ -1,28 +0,0 @@ -slug, $this->label, $this->inherits, $changes, $this->declaringClass, $this->textDomain); - } -} diff --git a/src/Role/Domain/Models/RoleModification.php b/src/Role/Domain/Models/RoleModification.php deleted file mode 100644 index 4c05d8b8..00000000 --- a/src/Role/Domain/Models/RoleModification.php +++ /dev/null @@ -1,25 +0,0 @@ -slug, $changes, $this->declaringClass); - } -} diff --git a/src/Role/Domain/Services/PostTypeCapabilityMap.php b/src/Role/Domain/Services/PostTypeCapabilityMap.php deleted file mode 100644 index ed875478..00000000 --- a/src/Role/Domain/Services/PostTypeCapabilityMap.php +++ /dev/null @@ -1,33 +0,0 @@ - $overrides The #[Capabilities] map, by generic name - * @return list - */ - public function capabilities(string $capabilityType, Access $access, array $overrides = []): array - { - $plural = $capabilityType.'s'; - - return array_map( - static fn (string $generic): string => $overrides[$generic] ?? str_replace('_posts', '_'.$plural, $generic), - $access->genericCapabilities() - ); - } -} diff --git a/src/Role/Domain/Services/RoleCompiler.php b/src/Role/Domain/Services/RoleCompiler.php deleted file mode 100644 index 3e5761d0..00000000 --- a/src/Role/Domain/Services/RoleCompiler.php +++ /dev/null @@ -1,228 +0,0 @@ -, _pollora?: array} - */ -final class RoleCompiler -{ - /** - * Key of the marker Pollora keeps in the role entries it touches. - */ - public const string MARKER = '_pollora'; - - /** - * @param array $storedRoles `WP_Roles::$roles` - * @param list $definitions Declared roles, with resolved grants - * @param list $modifications Modifications, with resolved grants - * @param list $superRoles Roles receiving every declared capability - * @param list $declaredCapabilities Capabilities of post types, taxonomies and capability sets - * @param Closure(string): void|null $warn Receives what could not be applied - * @return array - */ - public function compile( - array $storedRoles, - array $definitions, - array $modifications, - array $superRoles, - array $declaredCapabilities, - ?Closure $warn = null, - ): array { - $roles = $this->undo($storedRoles); - - $declared = []; - foreach ($definitions as $definition) { - $declared[$definition->slug] = $definition; - } - - foreach ($modifications as $modification) { - if (! isset($declared[$modification->slug])) { - $this->modify($roles, $modification, $warn); - } - } - - $resolved = []; - foreach ($declared as $slug => $definition) { - $roles[$slug] = [ - 'name' => $definition->label, - 'capabilities' => $this->capabilitiesOf($slug, $declared, $roles, $resolved, $warn), - self::MARKER => ['managed' => true], - ]; - } - - foreach ($modifications as $modification) { - if (isset($declared[$modification->slug])) { - $this->modify($roles, $modification, $warn); - } - } - - foreach ($superRoles as $superRole) { - if (isset($roles[$superRole])) { - $this->grant($roles[$superRole], $declaredCapabilities); - } - } - - return $roles; - } - - /** - * Removes what a previous compilation added: managed roles, granted capabilities, - * removed capabilities (restored with their original value). - * - * @param array $roles - * @return array - */ - private function undo(array $roles): array - { - foreach ($roles as $slug => $role) { - $marker = $role[self::MARKER] ?? null; - - if (! is_array($marker)) { - continue; - } - - if (($marker['managed'] ?? false) === true) { - unset($roles[$slug]); - - continue; - } - - foreach ($marker['granted'] ?? [] as $capability) { - unset($role['capabilities'][$capability]); - } - - foreach ($marker['removed'] ?? [] as $capability => $value) { - $role['capabilities'][$capability] = $value; - } - - unset($role[self::MARKER]); - $roles[$slug] = $role; - } - - return $roles; - } - - /** - * @param array $declared - * @param array $roles - * @param array> $resolved Memo of capabilities already resolved - * @param Closure(string): void|null $warn - * @param list $resolving Slugs being resolved, to stop a cycle - * @return array - */ - private function capabilitiesOf(string $slug, array $declared, array $roles, array &$resolved, ?Closure $warn, array $resolving = []): array - { - if (isset($resolved[$slug])) { - return $resolved[$slug]; - } - - $definition = $declared[$slug]; - $capabilities = []; - - if ($definition->inherits !== null) { - if (in_array($definition->inherits, $resolving, true)) { - $this->warn($warn, sprintf('The role "%s" inherits from itself through "%s"; inheritance ignored.', $slug, $definition->inherits)); - } elseif (isset($declared[$definition->inherits])) { - $capabilities = $this->capabilitiesOf($definition->inherits, $declared, $roles, $resolved, $warn, [...$resolving, $slug]); - } elseif (isset($roles[$definition->inherits])) { - $capabilities = $roles[$definition->inherits]['capabilities']; - } else { - $this->warn($warn, sprintf('The role "%s" inherits from "%s", which does not exist; it starts with no capability.', $slug, $definition->inherits)); - } - } - - foreach ($definition->changes->grants as $capability) { - $capabilities[$capability] = true; - } - - foreach ($definition->changes->removals as $capability) { - unset($capabilities[$capability]); - } - - return $resolved[$slug] = $capabilities; - } - - /** - * @param array $roles - * @param Closure(string): void|null $warn - */ - private function modify(array &$roles, RoleModification $modification, ?Closure $warn): void - { - if (! isset($roles[$modification->slug])) { - $this->warn($warn, sprintf('%s modifies the role "%s", which does not exist.', $modification->declaringClass, $modification->slug)); - - return; - } - - $role = &$roles[$modification->slug]; - $this->grant($role, $modification->changes->grants); - - foreach ($modification->changes->removals as $capability) { - if (! array_key_exists($capability, $role['capabilities'])) { - continue; - } - - if (! in_array($capability, $role[self::MARKER]['granted'] ?? [], true)) { - $role[self::MARKER]['removed'][$capability] = $role['capabilities'][$capability]; - } - - unset($role['capabilities'][$capability]); - } - } - - /** - * Grants capabilities, remembering those the role did not already have. - * - * @param StoredRole $role - * @param list $capabilities - */ - private function grant(array &$role, array $capabilities): void - { - $managed = ($role[self::MARKER]['managed'] ?? false) === true; - - foreach ($capabilities as $capability) { - if (($role['capabilities'][$capability] ?? false) === true) { - continue; - } - - $role['capabilities'][$capability] = true; - - if (! $managed) { - $role[self::MARKER]['granted'][] = $capability; - } - } - } - - /** - * @param Closure(string): void|null $warn - */ - private function warn(?Closure $warn, string $message): void - { - if ($warn instanceof Closure) { - $warn($message); - } - } -} diff --git a/src/Role/Domain/Services/StoredEntryClassifier.php b/src/Role/Domain/Services/StoredEntryClassifier.php deleted file mode 100644 index 75e49ddc..00000000 --- a/src/Role/Domain/Services/StoredEntryClassifier.php +++ /dev/null @@ -1,42 +0,0 @@ -}> $roles The roles WordPress has, by slug - */ - public function classify(string $entry, array $roles): string - { - if (isset($roles[$entry])) { - return self::ROLE; - } - - foreach ($roles as $role) { - if (array_key_exists($entry, $role['capabilities'] ?? [])) { - return self::CAPABILITY; - } - } - - return self::DEAD; - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleInjector.php b/src/Role/Infrastructure/Adapters/WordPressRoleInjector.php deleted file mode 100644 index f3f5a32f..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleInjector.php +++ /dev/null @@ -1,133 +0,0 @@ - What the last compilation could not apply - */ - private array $warnings = []; - - /** - * @param list $superRoles - */ - public function __construct( - private readonly RoleRegistry $registry, - private readonly RoleCompiler $compiler, - private readonly array $superRoles = ['administrator'], - private readonly ?LoggerInterface $logger = null, - ) {} - - /** - * Compiles the roles of a `WP_Roles` instance and puts them in place. Hooked on `wp_roles_init`. - */ - public function inject(WP_Roles $wpRoles): void - { - if ($this->registry->isEmpty() && ! $this->hasMarkedRoles($wpRoles->roles)) { - return; - } - - $this->warnings = []; - $warn = function (string $message): void { - $this->warnings[] = $message; - }; - - $roles = $this->compiler->compile( - $wpRoles->roles, - $this->registry->definitions($warn), - $this->registry->modifications($warn), - $this->superRoles, - $this->registry->declaredCapabilities(), - $warn, - ); - - $wpRoles->roles = $roles; - $wpRoles->role_objects = []; - $wpRoles->role_names = []; - - foreach ($roles as $slug => $role) { - $wpRoles->role_objects[$slug] = new WP_Role($slug, $role['capabilities']); - $wpRoles->role_names[$slug] = $role['name']; - } - } - - /** - * Injects declarations added after WordPress built its roles, and recomputes - * the current user's capabilities if they were already computed. - */ - public function refresh(): void - { - $wpRoles = $GLOBALS['wp_roles'] ?? null; - - if (! $wpRoles instanceof WP_Roles) { - return; - } - - $this->inject($wpRoles); - - if (\did_action('set_current_user') > 0) { - $user = \wp_get_current_user(); - - if ($user->exists()) { - $user->get_role_caps(); - } - } - } - - /** - * What the last compilation could not apply (an unknown inherited role, a - * modification of a role that does not exist…). - * - * @return list - */ - public function warnings(): array - { - return array_values(array_unique($this->warnings)); - } - - /** - * Logs what the last compilation could not apply. Hooked late, once every - * location has been discovered. - */ - public function reportWarnings(): void - { - foreach (array_unique($this->warnings) as $warning) { - $this->logger?->warning('Roles: '.$warning); - } - } - - /** - * @param array> $roles - */ - private function hasMarkedRoles(array $roles): bool - { - foreach ($roles as $role) { - if (isset($role[RoleCompiler::MARKER])) { - return true; - } - } - - return false; - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php b/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php deleted file mode 100644 index 369b1bd4..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php +++ /dev/null @@ -1,33 +0,0 @@ -registry->labelDomains()[$text] ?? null; - - return $domain === null ? $translation : translate($text, $domain); - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleStore.php b/src/Role/Infrastructure/Adapters/WordPressRoleStore.php deleted file mode 100644 index 3b9001e6..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleStore.php +++ /dev/null @@ -1,61 +0,0 @@ -optionName(), []); - - return is_array($roles) ? $roles : []; - } - - public function saveStoredRoles(array $roles): void - { - \update_option($this->optionName(), $roles); - } - - public function rolesOf(int $userId): array - { - $user = \get_userdata($userId); - - return $user instanceof \WP_User ? array_values($user->roles) : []; - } - - public function removeFromUser(int $userId, string $entry): void - { - $user = \get_userdata($userId); - - // remove_cap(), not remove_role(): WP_User::$roles only lists roles WordPress - // knows, so remove_role() ignores a role that no longer exists - if ($user instanceof \WP_User) { - $user->remove_cap($entry); - } - } - - public function addRoleToUser(int $userId, string $role): void - { - $user = \get_userdata($userId); - - if ($user instanceof \WP_User) { - $user->add_role($role); - } - } - - private function optionName(): string - { - /** @var \wpdb $wpdb */ - $wpdb = $GLOBALS['wpdb']; - - return $wpdb->get_blog_prefix().'user_roles'; - } -} diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleUsage.php b/src/Role/Infrastructure/Adapters/WordPressRoleUsage.php deleted file mode 100644 index 0fb6138e..00000000 --- a/src/Role/Infrastructure/Adapters/WordPressRoleUsage.php +++ /dev/null @@ -1,77 +0,0 @@ ->|null Stored capabilities by user ID, read once */ - private ?array $capabilities = null; - - public function entries(): array - { - $entries = []; - - foreach ($this->capabilities() as $capabilities) { - foreach ($capabilities as $name => $granted) { - if ($granted) { - $entries[(string) $name] = ($entries[(string) $name] ?? 0) + 1; - } - } - } - - ksort($entries); - - return $entries; - } - - public function usersWith(string $entry): array - { - $users = []; - - foreach ($this->capabilities() as $userId => $capabilities) { - if (! empty($capabilities[$entry])) { - $user = \get_userdata($userId); - $users[$userId] = $user instanceof \WP_User ? $user->user_login : '#'.$userId; - } - } - - return $users; - } - - /** - * @return array> - */ - private function capabilities(): array - { - if ($this->capabilities !== null) { - return $this->capabilities; - } - - /** @var \wpdb $wpdb */ - $wpdb = $GLOBALS['wpdb']; - - /** @var list $rows */ - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", - $wpdb->get_blog_prefix().'capabilities' - )); - - $this->capabilities = []; - - foreach ($rows as $row) { - $value = \is_serialized($row->meta_value) ? @unserialize(trim($row->meta_value), ['allowed_classes' => false]) : null; - $this->capabilities[(int) $row->user_id] = is_array($value) ? $value : []; - } - - return $this->capabilities; - } -} diff --git a/src/Role/Infrastructure/Services/RoleDiscovery.php b/src/Role/Infrastructure/Services/RoleDiscovery.php deleted file mode 100644 index 8ca88dbe..00000000 --- a/src/Role/Infrastructure/Services/RoleDiscovery.php +++ /dev/null @@ -1,113 +0,0 @@ - 'role', - ModifyRole::class => 'role', - CapabilitySet::class => 'capabilitySet', - PostType::class => 'postType', - Taxonomy::class => 'taxonomy', - ]; - - public function __construct( - private readonly RoleDefinitionBuilder $builder, - private readonly RoleRegistry $registry, - private readonly WordPressRoleInjector $injector, - private readonly ?LoggerInterface $logger = null, - ) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - $isClass = $structure instanceof DiscoveredClass && ! $structure->isAbstract; - - if (! $isClass && ! $structure instanceof DiscoveredEnum) { - return; - } - - foreach ($structure->attributes as $attribute) { - $kind = self::KINDS[$attribute->class] ?? null; - - if ($kind === null || ($kind === 'capabilitySet') === $isClass) { - continue; - } - - $this->getItems()->add($location, ['class' => $structure->namespace.'\\'.$structure->name, 'kind' => $kind]); - - return; - } - } - - public function apply(): void - { - foreach ($this->getItems() as ['class' => $class, 'kind' => $kind]) { - try { - match ($kind) { - 'role' => $this->addRole($class), - 'capabilitySet' => $this->registry->addCapabilitySet(array_map( - static fn (BackedEnum $case): string => (string) $case->value, - $class::cases() - )), - 'postType' => $this->registry->addPostType($class), - 'taxonomy' => $this->registry->addTaxonomy($class), - }; - } catch (\Throwable $throwable) { - $this->logger?->error(sprintf('Failed to register the role declaration %s: %s', $class, $throwable->getMessage()), ['exception' => $throwable]); - } - } - - $this->injector->refresh(); - } - - public function getIdentifier(): string - { - return 'roles'; - } - - /** - * @param class-string $class - */ - private function addRole(string $class): void - { - $declaration = $this->builder->build($class); - - if ($declaration instanceof RoleDefinition) { - $this->registry->addRole($declaration); - } else { - $this->registry->addModification($declaration); - } - } -} diff --git a/tests/Unit/Role/PostTypeCapabilityMapTest.php b/tests/Unit/Role/PostTypeCapabilityMapTest.php deleted file mode 100644 index 3ee26621..00000000 --- a/tests/Unit/Role/PostTypeCapabilityMapTest.php +++ /dev/null @@ -1,22 +0,0 @@ -capabilities('event', $access))->toBe($expected); -})->with([ - 'contributor' => [Access::Contributor, ['edit_events', 'delete_events']], - 'author' => [Access::Author, ['edit_events', 'delete_events', 'publish_events', 'edit_published_events', 'delete_published_events']], - 'editor' => [Access::Editor, [ - 'edit_events', 'delete_events', 'publish_events', 'edit_published_events', 'delete_published_events', - 'edit_others_events', 'delete_others_events', 'read_private_events', 'edit_private_events', 'delete_private_events', - ]], -]); - -it('uses the names mapped by #[Capabilities]', function (): void { - expect((new PostTypeCapabilityMap)->capabilities('venue', Access::Author, ['publish_posts' => 'open_venues'])) - ->toBe(['edit_venues', 'delete_venues', 'open_venues', 'edit_published_venues', 'delete_published_venues']); -}); diff --git a/tests/Unit/Role/RoleCompilerTest.php b/tests/Unit/Role/RoleCompilerTest.php deleted file mode 100644 index 57c90e9f..00000000 --- a/tests/Unit/Role/RoleCompilerTest.php +++ /dev/null @@ -1,152 +0,0 @@ - ['name' => 'Administrator', 'capabilities' => ['manage_options' => true, 'edit_posts' => true]], - 'editor' => ['name' => 'Editor', 'capabilities' => ['edit_posts' => true, 'edit_theme_options' => true, 'moderate_comments' => true]], - 'author' => ['name' => 'Author', 'capabilities' => ['read' => true, 'edit_posts' => true, 'publish_posts' => true, 'upload_files' => true]], - 'subscriber' => ['name' => 'Subscriber', 'capabilities' => ['read' => true]], - ]; -} - -function role(string $slug, ?string $inherits = null, array $grants = [], array $removals = [], string $label = 'Label'): RoleDefinition -{ - return new RoleDefinition($slug, $label, $inherits, new RoleChanges($grants, $removals), 'Class\\'.$slug); -} - -function modification(string $slug, array $grants = [], array $removals = []): RoleModification -{ - return new RoleModification($slug, new RoleChanges($grants, $removals), 'Class\\Modify'.ucfirst($slug)); -} - -beforeEach(function (): void { - $this->compiler = new RoleCompiler; -}); - -it('builds a declared role from its parent, plus grants, minus removals, and marks it', function (): void { - $roles = $this->compiler->compile(storedRoles(), [role('event_manager', 'author', ['export_attendees'], ['publish_posts'], 'Event manager')], [], [], []); - - expect($roles['event_manager'])->toBe([ - 'name' => 'Event manager', - 'capabilities' => ['read' => true, 'edit_posts' => true, 'upload_files' => true, 'export_attendees' => true], - '_pollora' => ['managed' => true], - ]); -}); - -it('inherits from another declared role, whatever the declaration order', function (): void { - $roles = $this->compiler->compile(storedRoles(), [ - role('steward', 'event_manager', ['scan_tickets']), - role('event_manager', 'subscriber', ['export_attendees']), - ], [], [], []); - - expect($roles['steward']['capabilities'])->toBe(['read' => true, 'export_attendees' => true, 'scan_tickets' => true]); -}); - -it('replaces a stored role with the same slug', function (): void { - $stored = [...storedRoles(), 'event_manager' => ['name' => 'Old', 'capabilities' => ['delete_users' => true]]]; - - $roles = $this->compiler->compile($stored, [role('event_manager', null, ['read'])], [], [], []); - - expect($roles['event_manager']['capabilities'])->toBe(['read' => true]); -}); - -it('drops a marked role that is no longer declared', function (): void { - $stored = [...storedRoles(), 'gone' => ['name' => 'Gone', 'capabilities' => ['read' => true], '_pollora' => ['managed' => true]]]; - - expect($this->compiler->compile($stored, [], [], [], []))->not->toHaveKey('gone'); -}); - -it('modifies an existing role and records what it changed', function (): void { - $roles = $this->compiler->compile(storedRoles(), [], [modification('editor', ['scan_tickets', 'edit_posts'], ['edit_theme_options', 'not_there'])], [], []); - - expect($roles['editor'])->toBe([ - 'name' => 'Editor', - 'capabilities' => ['edit_posts' => true, 'moderate_comments' => true, 'scan_tickets' => true], - '_pollora' => ['granted' => ['scan_tickets'], 'removed' => ['edit_theme_options' => true]], - ]); -}); - -it('undoes previous modifications before applying the current ones', function (): void { - $previous = $this->compiler->compile(storedRoles(), [], [modification('editor', ['old_grant'], ['moderate_comments'])], [], []); - - $roles = $this->compiler->compile($previous, [], [modification('editor', ['scan_tickets'])], [], []); - - expect($roles['editor']['capabilities'])->toBe(['edit_posts' => true, 'edit_theme_options' => true, 'moderate_comments' => true, 'scan_tickets' => true]); -}); - -it('gives back the stored roles once nothing is declared any more', function (): void { - $compiled = $this->compiler->compile( - storedRoles(), - [role('event_manager', 'author', ['export_attendees'])], - [modification('editor', ['scan_tickets'], ['edit_theme_options'])], - ['administrator'], - ['edit_events'], - ); - - // Same roles and capabilities; only the order of the restored keys may differ. - expect($this->compiler->compile($compiled, [], [], ['administrator'], []))->toEqual(storedRoles()); -}); - -it('gives the same result when compiled twice', function (): void { - $arguments = [ - [role('event_manager', 'author', ['export_attendees'], ['publish_posts'])], - [modification('editor', ['scan_tickets'], ['edit_theme_options']), modification('event_manager', ['moderate_comments'])], - ['administrator'], - ['edit_events', 'export_attendees'], - ]; - - $once = $this->compiler->compile(storedRoles(), ...$arguments); - - expect($this->compiler->compile($once, ...$arguments))->toBe($once); -}); - -it('applies modifications of a declared role after building it', function (): void { - $roles = $this->compiler->compile(storedRoles(), [role('event_manager', 'subscriber')], [modification('event_manager', ['moderate_comments'], ['read'])], [], []); - - expect($roles['event_manager']['capabilities'])->toBe(['moderate_comments' => true]); -}); - -it('lets a declared role inherit what a modification added to its parent', function (): void { - $roles = $this->compiler->compile(storedRoles(), [role('event_manager', 'subscriber')], [modification('subscriber', ['view_events'])], [], []); - - expect($roles['event_manager']['capabilities'])->toBe(['read' => true, 'view_events' => true]); -}); - -it('gives the super roles every declared capability', function (): void { - $roles = $this->compiler->compile(storedRoles(), [], [], ['administrator', 'missing_super_role'], ['edit_events', 'edit_posts']); - - expect($roles['administrator']['capabilities'])->toBe(['manage_options' => true, 'edit_posts' => true, 'edit_events' => true]) - ->and($roles['administrator']['_pollora'])->toBe(['granted' => ['edit_events']]); -}); - -it('warns about what it cannot apply', function (): void { - $warnings = []; - $warn = function (string $warning) use (&$warnings): void { - $warnings[] = $warning; - }; - - $roles = $this->compiler->compile(storedRoles(), [ - role('orphan', 'missing_parent', ['read']), - role('loop_a', 'loop_b'), - role('loop_b', 'loop_a', ['read']), - ], [modification('missing_role', ['read'])], [], [], $warn); - - expect($roles['orphan']['capabilities'])->toBe(['read' => true]) - ->and($warnings)->toBe([ - 'Class\ModifyMissing_role modifies the role "missing_role", which does not exist.', - 'The role "orphan" inherits from "missing_parent", which does not exist; it starts with no capability.', - 'The role "loop_b" inherits from itself through "loop_a"; inheritance ignored.', - ]); -}); - -it('warns nowhere when nobody listens', function (): void { - expect($this->compiler->compile(storedRoles(), [role('orphan', 'missing_parent')], [], [], []))->toHaveKey('orphan'); -}); diff --git a/tests/Unit/Role/RoleDefinitionBuilderTest.php b/tests/Unit/Role/RoleDefinitionBuilderTest.php deleted file mode 100644 index c03cefd3..00000000 --- a/tests/Unit/Role/RoleDefinitionBuilderTest.php +++ /dev/null @@ -1,105 +0,0 @@ -builder = new RoleDefinitionBuilder(new CapabilityOwnerReader, ['administrator']); -}); - -it('builds a role from #[Role] and its grants', function (): void { - $role = $this->builder->build(EventManager::class); - - expect($role)->toBeInstanceOf(RoleDefinition::class) - ->and($role->slug)->toBe('event_manager') - ->and($role->label)->toBe('Event manager') - ->and($role->textDomain)->toBeNull() - ->and($role->inherits)->toBe('author') - ->and($role->declaringClass)->toBe(EventManager::class) - ->and($role->changes->grants)->toBe(['export_attendees', 'scan_tickets', 'upload_files']) - ->and($role->changes->removals)->toBe(['publish_posts']) - ->and($role->changes->postTypeGrants)->toBe([ - ['postType' => Event::class, 'access' => Access::Editor], - ['postType' => 'venue', 'access' => Access::Author], - ]) - ->and($role->changes->taxonomyGrants)->toBe([Genre::class]); -}); - -it('resolves an inherited role given by class, and defaults the label to the class name', function (): void { - $role = $this->builder->build(Steward::class); - - expect($role->inherits)->toBe('event_manager') - ->and($role->label)->toBe('Steward') - ->and($role->changes->grants)->toBe(['manage_options']); -}); - -it('keeps the text domain of the label, through resolution', function (): void { - $role = $this->builder->build(Usher::class); - - expect($role->textDomain)->toBe('events') - ->and($role->withChanges($role->changes)->textDomain)->toBe('events'); -}); - -it('builds a modification from #[ModifyRole]', function (): void { - $modification = $this->builder->build(EditorAdjustments::class); - - expect($modification)->toBeInstanceOf(RoleModification::class) - ->and($modification->slug)->toBe('editor') - ->and($modification->changes->grants)->toBe(['scan_tickets']) - ->and($modification->changes->removals)->toBe(['edit_theme_options']) - ->and($modification->changes->postTypeGrants)->toBe([['postType' => Event::class, 'access' => Access::Contributor]]); -}); - -it('refuses a declaration that would grant the wrong rights', function (string $class, string $message): void { - expect(fn () => $this->builder->build($class))->toThrow(InvalidRoleDefinitionException::class, $message); -})->with([ - 'core role redeclared' => [RedeclaresCoreRole::class, '"editor" is a core role; adjust it with #[ModifyRole(\'editor\')]'], - 'inherits a super role' => [InheritsSuperRole::class, 'cannot inherit from the super role "administrator"'], - 'sensitive capability' => [GrantsSensitive::class, 'granting "edit_users" lets a user take over the site; confirm it with allowSensitive: true'], - 'sensitive capability on a modification' => [ModifiesWithSensitive::class, 'granting "install_plugins"'], - 'granted and removed' => [GrantsAndRemoves::class, '"read" is both granted and removed'], - 'role and modification' => [RoleAndModify::class, 'not both'], - 'no attribute' => [NoRoleAttribute::class, 'carries neither #[Role] nor #[ModifyRole]'], - 'post type sharing post capabilities' => [GrantsSharedPostType::class, 'the post type "article" shares the capabilities of posts; give Tests\Unit\Role\Fixtures\Article its own with #[CapabilityType(\'article\')]'], - 'not a post type' => [GrantsNotAPostType::class, 'which is not a #[PostType]'], - 'taxonomy sharing category capabilities' => [GrantsSharedTaxonomy::class, 'the taxonomy "topic" shares the capabilities of categories'], - 'not a taxonomy' => [GrantsNotATaxonomy::class, 'which is not a #[Taxonomy]'], - 'inherits a class that is not a role' => [InheritsNotARole::class, 'which is not a #[Role]'], -]); - -it('reads post type and taxonomy capabilities, and ignores other classes', function (): void { - $owners = new CapabilityOwnerReader; - - expect($owners->postType(Event::class))->toBe(['slug' => 'event', 'capabilityType' => 'event', 'overrides' => []]) - ->and($owners->taxonomy(Genre::class))->toBe(['slug' => 'genre', 'capabilities' => ['manage_genres', 'edit_genres', 'assign_genres']]) - ->and($owners->postType(Genre::class))->toBeNull() - ->and($owners->taxonomy(Event::class))->toBeNull() - ->and($owners->postType('not-a-class'))->toBeNull() - ->and($owners->taxonomy('not-a-class'))->toBeNull(); -}); diff --git a/tests/Unit/Role/RoleDiscoveryTest.php b/tests/Unit/Role/RoleDiscoveryTest.php deleted file mode 100644 index c42a5367..00000000 --- a/tests/Unit/Role/RoleDiscoveryTest.php +++ /dev/null @@ -1,97 +0,0 @@ -registry = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); - $this->logger = Mockery::mock(LoggerInterface::class); - $this->discovery = new RoleDiscovery( - new RoleDefinitionBuilder(new CapabilityOwnerReader), - $this->registry, - new WordPressRoleInjector($this->registry, new RoleCompiler), - $this->logger, - ); - $this->location = new DiscoveryLocation('Tests\\', __DIR__); - $this->discover = function (string ...$classes): void { - foreach ($classes as $class) { - $structure = enum_exists($class) - ? DiscoveredEnum::fromReflection(new ReflectionEnum($class)) - : DiscoveredClass::fromReflection(new ReflectionClass($class)); - $this->discovery->discover($this->location, $structure); - } - }; -}); - -afterEach(function (): void { - unset($GLOBALS['wp_roles']); -}); - -it('keeps roles, modifications, capability sets, post types and taxonomies', function (): void { - ($this->discover)(EventManager::class, EditorAdjustments::class, EventCap::class, Event::class, Genre::class, AbstractRoleDeclaration::class, RoleOnAnEnum::class, stdClass::class); - - expect(iterator_to_array($this->discovery->getItems()))->toBe([ - ['class' => EventManager::class, 'kind' => 'role'], - ['class' => EditorAdjustments::class, 'kind' => 'role'], - ['class' => EventCap::class, 'kind' => 'capabilitySet'], - ['class' => Event::class, 'kind' => 'postType'], - ['class' => Genre::class, 'kind' => 'taxonomy'], - ]); -}); - -it('fills the registry and injects into the roles WordPress already has', function (): void { - $GLOBALS['wp_roles'] = new WP_Roles; - $GLOBALS['wp_roles']->roles = ['administrator' => ['name' => 'Administrator', 'capabilities' => []], 'author' => ['name' => 'Author', 'capabilities' => ['read' => true]], 'editor' => ['name' => 'Editor', 'capabilities' => []]]; - Brain\Monkey\Functions\when('did_action')->justReturn(0); - ($this->discover)(EventManager::class, EditorAdjustments::class, EventCap::class, Event::class, Article::class, Genre::class); - - $this->discovery->apply(); - - expect($GLOBALS['wp_roles']->roles)->toHaveKey('event_manager') - ->and($GLOBALS['wp_roles']->roles['editor']['capabilities'])->toHaveKeys(['scan_tickets', 'edit_events']) - ->and($GLOBALS['wp_roles']->roles['administrator']['capabilities'])->toHaveKeys(['export_attendees', 'edit_others_events', 'manage_genres']); -}); - -it('logs a declaration it cannot register and carries on', function (): void { - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/RedeclaresCoreRole: "editor" is a core role/'), Mockery::type('array')); - ($this->discover)(RedeclaresCoreRole::class, EventManager::class); - - $this->discovery->apply(); - - expect($this->registry->definitions())->toHaveCount(1); -}); - -it('identifies itself as roles', function (): void { - expect($this->discovery->getIdentifier())->toBe('roles'); -}); diff --git a/tests/Unit/Role/RoleReferenceTest.php b/tests/Unit/Role/RoleReferenceTest.php deleted file mode 100644 index 8b7c2a47..00000000 --- a/tests/Unit/Role/RoleReferenceTest.php +++ /dev/null @@ -1,29 +0,0 @@ -toBe('editor'); -}); - -it('reads the slug of a #[Role] or #[ModifyRole] class', function (): void { - expect(RoleReference::slug(EventManager::class))->toBe('event_manager') - ->and(RoleReference::slug(EditorAdjustments::class))->toBe('editor'); -}); - -it('refuses a class that names no role', function (): void { - RoleReference::slug(Venue::class); -})->throws(InvalidArgumentException::class, Venue::class.' does not name a role'); - -it('matches when the user has one of the roles, ignoring case', function (): void { - expect(RoleReference::matchesAny(['author', 'event_manager'], ['editor', EventManager::class]))->toBeTrue() - ->and(RoleReference::matchesAny(['Editor'], ['EDITOR']))->toBeTrue() - ->and(RoleReference::matchesAny(['author'], ['editor', EventManager::class]))->toBeFalse() - ->and(RoleReference::matchesAny([], ['editor']))->toBeFalse() - ->and(RoleReference::matchesAny(['editor'], []))->toBeFalse(); -}); diff --git a/tests/Unit/Role/RoleRegistryTest.php b/tests/Unit/Role/RoleRegistryTest.php deleted file mode 100644 index 9210874d..00000000 --- a/tests/Unit/Role/RoleRegistryTest.php +++ /dev/null @@ -1,124 +0,0 @@ -registry = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); - $this->build = fn (string $class): RoleDefinition|\Pollora\Role\Domain\Models\RoleModification => (new RoleDefinitionBuilder(new CapabilityOwnerReader))->build($class); -}); - -it('resolves post type and taxonomy grants into capability names', function (): void { - $this->registry->addPostType(Venue::class); - $this->registry->addRole(($this->build)(EventManager::class)); - - $grants = $this->registry->definitions()[0]->changes->grants; - - expect($grants)->toContain('export_attendees', 'upload_files', 'edit_others_events', 'delete_private_events', 'open_venues', 'edit_published_venues', 'manage_genres', 'assign_genres') - ->not->toContain('edit_others_venues') - ->and($grants)->toBe(array_values(array_unique($grants))); -}); - -it('ignores, with a warning, a post type slug it has not discovered', function (): void { - $warnings = []; - $this->registry->addRole(($this->build)(EventManager::class)); - - $grants = $this->registry->definitions(function (string $warning) use (&$warnings): void { - $warnings[] = $warning; - })[0]->changes->grants; - - expect($grants)->not->toContain('open_venues') - ->and($warnings)->toBe(['Tests\Unit\Role\Fixtures\EventManager: the post type "venue" was not found with its own capabilities; its grant is ignored.']); -}); - -it('ignores, with a warning, a taxonomy slug it has not discovered', function (): void { - $warnings = []; - $modification = ($this->build)(EditorAdjustments::class); - $this->registry->addModification($modification->withChanges(new RoleChanges(taxonomyGrants: ['genre']))); - - $this->registry->modifications(function (string $warning) use (&$warnings): void { - $warnings[] = $warning; - }); - - expect($warnings)->toBe(['Tests\Unit\Role\Fixtures\EditorAdjustments: the taxonomy "genre" was not found with its own capabilities; its grant is ignored.']); -}); - -it('collects every declared capability for the super roles', function (): void { - $this->registry->addPostType(Event::class); - $this->registry->addPostType(Article::class); - $this->registry->addTaxonomy(Genre::class); - $this->registry->addTaxonomy(Topic::class); - $this->registry->addCapabilitySet(['export_attendees', 'scan_tickets']); - $this->registry->addCapabilitySet(['scan_tickets']); - - expect($this->registry->declaredCapabilities())->toBe([ - 'export_attendees', 'scan_tickets', - 'edit_events', 'delete_events', 'publish_events', 'edit_published_events', 'delete_published_events', - 'edit_others_events', 'delete_others_events', 'read_private_events', 'edit_private_events', 'delete_private_events', - 'manage_genres', 'edit_genres', 'assign_genres', - ]); -}); - -it('starts empty', function (): void { - expect($this->registry->isEmpty())->toBeTrue(); - - $this->registry->addCapabilitySet(['scan_tickets']); - - expect($this->registry->isEmpty())->toBeFalse(); -}); - -it('refuses a role slug declared by two classes', function (): void { - $this->registry->addRole(($this->build)(EventManager::class)); - $this->registry->addRole(($this->build)(EventManager::class)); - - $this->registry->addRole(($this->build)(DuplicateEventManager::class)); -})->throws(InvalidRoleDefinitionException::class, 'the role "event_manager" is already declared by Tests\Unit\Role\Fixtures\EventManager'); - -it('refuses an inheritance loop', function (): void { - $this->registry->addRole(($this->build)(LoopA::class)); - - $this->registry->addRole(($this->build)(LoopB::class)); -})->throws(InvalidRoleDefinitionException::class, 'inheritance loops: loop_b → loop_a → loop_b'); - -it('refuses modifications of the same role that contradict each other', function (): void { - $this->registry->addModification(($this->build)(EditorAdjustments::class)); - $this->registry->addModification(($this->build)(AlsoAdjustsEditor::class)); - - $this->registry->addModification(($this->build)(ContradictsEditorAdjustments::class)); -})->throws(InvalidRoleDefinitionException::class, 'it contradicts Tests\Unit\Role\Fixtures\EditorAdjustments on the role "editor": "edit_theme_options" is both granted and removed.'); - -it('keeps one modification per class', function (): void { - $this->registry->addModification(($this->build)(EditorAdjustments::class)); - $this->registry->addModification(($this->build)(EditorAdjustments::class)); - - expect($this->registry->modifications())->toHaveCount(1); -}); - -it('lists the text domain of each label that declares one', function (): void { - $this->registry->addRole(($this->build)(Usher::class)); - $this->registry->addRole(($this->build)(LoopB::class)); - - expect($this->registry->labelDomains())->toBe(['Usher' => 'events']); -}); diff --git a/tests/Unit/Role/WordPressRoleInjectorTest.php b/tests/Unit/Role/WordPressRoleInjectorTest.php deleted file mode 100644 index 6ee0b17a..00000000 --- a/tests/Unit/Role/WordPressRoleInjectorTest.php +++ /dev/null @@ -1,115 +0,0 @@ -roles = $roles; - - return $wpRoles; -} - -beforeEach(function (): void { - $this->registry = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); - $this->logger = Mockery::mock(LoggerInterface::class); - $this->injector = new WordPressRoleInjector($this->registry, new RoleCompiler, ['administrator'], $this->logger); - $this->stored = [ - 'administrator' => ['name' => 'Administrator', 'capabilities' => ['manage_options' => true]], - 'author' => ['name' => 'Author', 'capabilities' => ['read' => true, 'publish_posts' => true]], - ]; -}); - -afterEach(function (): void { - unset($GLOBALS['wp_roles']); -}); - -it('puts the compiled roles, role objects and names into WP_Roles', function (): void { - $this->registry->addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(EventManager::class)); - $wpRoles = wpRolesWith($this->stored); - - $this->injector->inject($wpRoles); - - expect($wpRoles->roles)->toHaveKeys(['administrator', 'author', 'event_manager']) - ->and($wpRoles->role_names['event_manager'])->toBe('Event manager') - ->and($wpRoles->role_objects['event_manager'])->toBeInstanceOf(WP_Role::class) - ->and($wpRoles->role_objects['event_manager']->capabilities)->toHaveKey('export_attendees') - ->and($wpRoles->role_objects['event_manager']->capabilities)->not->toHaveKey('publish_posts'); -}); - -it('leaves WP_Roles alone when the project declares nothing', function (): void { - $wpRoles = wpRolesWith($this->stored); - - $this->injector->inject($wpRoles); - - expect($wpRoles->roles)->toBe($this->stored) - ->and($wpRoles->role_objects)->toBe([]); -}); - -it('removes what an earlier version of the code left in the database, even when nothing is declared now', function (): void { - $wpRoles = wpRolesWith([...$this->stored, 'gone' => ['name' => 'Gone', 'capabilities' => [], '_pollora' => ['managed' => true]]]); - - $this->injector->inject($wpRoles); - - expect($wpRoles->roles)->toBe($this->stored); -}); - -it('injects into the existing WP_Roles and recomputes the current user capabilities on refresh', function (): void { - $this->registry->addCapabilitySet(['scan_tickets']); - $GLOBALS['wp_roles'] = wpRolesWith($this->stored); - $user = Mockery::mock(); - $user->shouldReceive('exists')->andReturn(true); - $user->shouldReceive('get_role_caps')->once(); - Functions\when('did_action')->justReturn(1); - Functions\when('wp_get_current_user')->justReturn($user); - - $this->injector->refresh(); - - expect($GLOBALS['wp_roles']->roles['administrator']['capabilities'])->toHaveKey('scan_tickets'); -}); - -it('does not touch a current user that is not logged in', function (): void { - $GLOBALS['wp_roles'] = wpRolesWith($this->stored); - $user = Mockery::mock(); - $user->shouldReceive('exists')->andReturn(false); - $user->shouldNotReceive('get_role_caps'); - Functions\when('did_action')->justReturn(1); - Functions\when('wp_get_current_user')->justReturn($user); - - $this->injector->refresh(); -}); - -it('waits for the current user before recomputing capabilities', function (): void { - $GLOBALS['wp_roles'] = wpRolesWith($this->stored); - Functions\when('did_action')->justReturn(0); - Functions\expect('wp_get_current_user')->never(); - - $this->injector->refresh(); -}); - -it('does nothing on refresh before WordPress has its roles', function (): void { - Functions\expect('did_action')->never(); - - $this->injector->refresh(); -}); - -it('logs each warning of the last compilation once', function (): void { - $this->registry->addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(EventManager::class)); - $this->injector->inject(wpRolesWith($this->stored)); - $this->injector->inject(wpRolesWith($this->stored)); - $this->logger->shouldReceive('warning')->once()->with('Roles: Tests\Unit\Role\Fixtures\EventManager: the post type "venue" was not found with its own capabilities; its grant is ignored.'); - - $this->injector->reportWarnings(); -}); diff --git a/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php b/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php deleted file mode 100644 index 261a37c5..00000000 --- a/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php +++ /dev/null @@ -1,29 +0,0 @@ -addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(Usher::class)); - - $this->translator = new WordPressRoleLabelTranslator($registry); - Functions\when('translate')->alias(fn (string $text, string $domain): string => $domain === 'events' ? 'Placeur' : $text); -}); - -it("translates a declared label with the role's text domain", function (): void { - expect($this->translator->translate('Usher', 'Usher', 'User role'))->toBe('Placeur'); -}); - -it('keeps a translation WordPress already found, and other texts', function (): void { - expect($this->translator->translate('Huissier', 'Usher', 'User role'))->toBe('Huissier') - ->and($this->translator->translate('Usher', 'Usher', 'noun'))->toBe('Usher') - ->and($this->translator->translate('Editor', 'Editor', 'User role'))->toBe('Editor'); -}); diff --git a/tests/Unit/Role/WordPressRoleStoreTest.php b/tests/Unit/Role/WordPressRoleStoreTest.php deleted file mode 100644 index aa1a5bcf..00000000 --- a/tests/Unit/Role/WordPressRoleStoreTest.php +++ /dev/null @@ -1,55 +0,0 @@ -previousWpdb = $GLOBALS['wpdb'] ?? null; - $GLOBALS['wpdb'] = new class - { - public function get_blog_prefix(): string - { - return 'wp_2_'; - } - }; -}); - -afterEach(function (): void { - $GLOBALS['wpdb'] = $this->previousWpdb; -}); - -it('reads and writes the roles option of the current site', function (): void { - $calls = []; - Functions\when('get_option')->alias(function (string $name) use (&$calls): array { - $calls[] = 'get '.$name; - - return ['editor' => ['name' => 'Editor', 'capabilities' => []]]; - }); - Functions\when('update_option')->alias(function (string $name, array $value) use (&$calls): bool { - $calls[] = 'update '.$name.': '.implode(', ', array_keys($value)); - - return true; - }); - - $store = new WordPressRoleStore; - $stored = $store->storedRoles(); - $store->saveStoredRoles(['author' => ['name' => 'Author', 'capabilities' => []]]); - - expect($stored)->toHaveKey('editor') - ->and($calls)->toBe(['get wp_2_user_roles', 'update wp_2_user_roles: author']); -}); - -it('takes a role WordPress no longer knows off a user through remove_cap()', function (): void { - $user = Mockery::mock('WP_User'); - $user->shouldReceive('remove_cap')->once()->with('event_manager'); - $user->shouldReceive('add_role')->once()->with('subscriber'); - Functions\when('get_userdata')->justReturn($user); - - $store = new WordPressRoleStore; - $store->removeFromUser(3, 'event_manager'); - $store->addRoleToUser(3, 'subscriber'); -}); diff --git a/tests/Unit/Role/WordPressRoleUsageTest.php b/tests/Unit/Role/WordPressRoleUsageTest.php deleted file mode 100644 index 656d60e6..00000000 --- a/tests/Unit/Role/WordPressRoleUsageTest.php +++ /dev/null @@ -1,57 +0,0 @@ -previousWpdb = $GLOBALS['wpdb'] ?? null; - $this->queries = []; - - // Only what the adapter uses: the usermeta table, the blog prefix, prepare() and get_results() - $GLOBALS['wpdb'] = new class($this) - { - public string $usermeta = 'wp_usermeta'; - - public function __construct(private readonly object $test) {} - - public function get_blog_prefix(): string - { - return 'wp_'; - } - - public function prepare(string $query, mixed ...$args): string - { - $this->test->queries[] = [$query, $args]; - - return $query; - } - - public function get_results(string $query): array - { - return [ - (object) ['user_id' => '1', 'meta_value' => serialize(['administrator' => true])], - (object) ['user_id' => '2', 'meta_value' => serialize(['event_manager' => true, 'old_cap' => false])], - (object) ['user_id' => '3', 'meta_value' => serialize(['event_manager' => true, 'moderate_comments' => true])], - (object) ['user_id' => '4', 'meta_value' => 'O:8:"stdClass":0:{}'], - ]; - } - }; - - Functions\when('is_serialized')->alias(fn (string $value): bool => preg_match('/^[aO]:/', $value) === 1); - Functions\when('get_userdata')->alias(fn (int $id): false => false); -}); - -afterEach(function (): void { - $GLOBALS['wpdb'] = $this->previousWpdb; -}); - -it('counts the granted entries users carry, read once from the current site', function (): void { - $usage = new WordPressRoleUsage; - - expect($usage->entries())->toBe(['administrator' => 1, 'event_manager' => 2, 'moderate_comments' => 1]) - ->and($usage->usersWith('event_manager'))->toBe([2 => '#2', 3 => '#3']) - ->and($this->queries)->toHaveCount(1) - ->and($this->queries[0][1])->toBe(['wp_capabilities']); -});