diff --git a/src/Policy/MapResolver.php b/src/Policy/MapResolver.php index 170ed71..2a9b209 100644 --- a/src/Policy/MapResolver.php +++ b/src/Policy/MapResolver.php @@ -89,20 +89,29 @@ public function map(string $resourceClass, callable|object|string $policy) /** * {@inheritDoc} * - * @throws \InvalidArgumentException When a resource is not an object. + * Accepts either an object instance or a fully-qualified class name registered in + * the map. Strings that are not valid class names continue to raise + * InvalidArgumentException. + * + * @throws \InvalidArgumentException When a resource is neither an object nor a fully-qualified class name. * @throws \Authorization\Policy\Exception\MissingPolicyException When a policy for a resource has not been defined. */ public function getPolicy($resource): mixed { - if (!is_object($resource)) { - $message = sprintf('Resource must be an object, `%s` given.', gettype($resource)); + if (is_object($resource)) { + $class = $resource::class; + } elseif (is_string($resource) && class_exists($resource)) { + $class = $resource; + } else { + $message = sprintf( + 'Resource must be an object or fully-qualified class name, `%s` given.', + is_string($resource) ? $resource : gettype($resource), + ); throw new InvalidArgumentException($message); } - $class = $resource::class; - if (!isset($this->map[$class])) { - throw new MissingPolicyException($resource); + throw new MissingPolicyException(is_object($resource) ? $resource : [$class]); } $policy = $this->map[$class]; diff --git a/src/Policy/OrmResolver.php b/src/Policy/OrmResolver.php index 9438aba..a078802 100644 --- a/src/Policy/OrmResolver.php +++ b/src/Policy/OrmResolver.php @@ -65,7 +65,11 @@ public function __construct( } /** - * Get a policy for an ORM Table, Entity or Query. + * Get a policy for an ORM Table, Entity, Query or class name string. + * + * Accepting an entity/table fully-qualified class name as the resource allows checks + * like `$user->can('add', Article::class)` where no instance is on hand + * (e.g. menu rendering before a `newEmptyEntity()`). * * @param mixed $resource The resource. * @return mixed @@ -75,10 +79,10 @@ public function __construct( public function getPolicy(mixed $resource): mixed { if ($resource instanceof EntityInterface) { - return $this->getEntityPolicy($resource); + return $this->getEntityPolicy($resource::class); } if ($resource instanceof RepositoryInterface) { - return $this->getRepositoryPolicy($resource); + return $this->getRepositoryPolicy($resource::class); } if ($resource instanceof QueryInterface) { $repo = $resource->getRepository(); @@ -86,40 +90,48 @@ public function getPolicy(mixed $resource): mixed throw new RuntimeException('No repository set for the query.'); } - return $this->getRepositoryPolicy($repo); + return $this->getRepositoryPolicy($repo::class); + } + if (is_string($resource) && class_exists($resource)) { + if (is_subclass_of($resource, EntityInterface::class)) { + return $this->getEntityPolicy($resource); + } + if (is_subclass_of($resource, RepositoryInterface::class)) { + return $this->getRepositoryPolicy($resource); + } + + throw new MissingPolicyException([$resource]); } throw new MissingPolicyException([get_debug_type($resource)]); } /** - * Get a policy for an entity + * Get a policy for an entity class. * - * @param \Cake\Datasource\EntityInterface $entity The entity to get a policy for + * @param string $class The entity class name to get a policy for. * @return mixed */ - protected function getEntityPolicy(EntityInterface $entity): mixed + protected function getEntityPolicy(string $class): mixed { - $class = $entity::class; $entityNamespace = '\Model\Entity\\'; $namespace = str_replace('\\', '/', substr($class, 0, (int)strpos($class, $entityNamespace))); - $name = str_replace('\\', '/', substr($class, strpos($class, $entityNamespace) + strlen($entityNamespace))); + $name = str_replace('\\', '/', substr($class, (int)strpos($class, $entityNamespace) + strlen($entityNamespace))); return $this->findPolicy($class, $name, $namespace); } /** - * Get a policy for a table + * Get a policy for a table/repository class. * - * @param \Cake\Datasource\RepositoryInterface $table The table/repository to get a policy for. + * @param string $class The table/repository class name to get a policy for. * @return mixed */ - protected function getRepositoryPolicy(RepositoryInterface $table): mixed + protected function getRepositoryPolicy(string $class): mixed { - $class = $table::class; $tableNamespace = '\Model\Table\\'; $namespace = str_replace('\\', '/', substr($class, 0, (int)strpos($class, $tableNamespace))); - $name = str_replace('\\', '/', substr($class, strpos($class, $tableNamespace) + strlen($tableNamespace))); + $name = str_replace('\\', '/', substr($class, (int)strpos($class, $tableNamespace) + strlen($tableNamespace))); return $this->findPolicy($class, $name, $namespace); } diff --git a/tests/TestCase/Policy/MapResolverTest.php b/tests/TestCase/Policy/MapResolverTest.php index abbf2f5..82325fb 100644 --- a/tests/TestCase/Policy/MapResolverTest.php +++ b/tests/TestCase/Policy/MapResolverTest.php @@ -92,11 +92,30 @@ public function testGetPolicyPrimitive(): void $resolver = new MapResolver(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Resource must be an object, `string` given.'); + $this->expectExceptionMessage('Resource must be an object or fully-qualified class name, `Foo` given.'); $resolver->getPolicy('Foo'); } + public function testGetPolicyClassNameAsResource(): void + { + $resolver = new MapResolver(); + $resolver->map(Article::class, ArticlePolicy::class); + + $result = $resolver->getPolicy(Article::class); + $this->assertInstanceOf(ArticlePolicy::class, $result); + } + + public function testGetPolicyUnregisteredClassString(): void + { + $resolver = new MapResolver(); + + $this->expectException(MissingPolicyException::class); + $this->expectExceptionMessage('Policy for `TestApp\Model\Entity\Article` has not been defined.'); + + $resolver->getPolicy(Article::class); + } + public function testGetPolicyMissing(): void { $resolver = new MapResolver(); diff --git a/tests/TestCase/Policy/OrmResolverTest.php b/tests/TestCase/Policy/OrmResolverTest.php index 4e1dd0c..6ef74a9 100644 --- a/tests/TestCase/Policy/OrmResolverTest.php +++ b/tests/TestCase/Policy/OrmResolverTest.php @@ -28,7 +28,9 @@ use OverridePlugin\Policy\TagPolicy as OverrideTagPolicy; use stdClass; use TestApp\Model\Entity\Article; +use TestApp\Model\Entity\FakeEntity; use TestApp\Model\Entity\SubDir\Widget; +use TestApp\Model\Table\ArticlesTable; use TestApp\Model\Table\SubDir\WidgetsTable; use TestApp\Policy\ArticlePolicy; use TestApp\Policy\ArticlesTablePolicy; @@ -147,6 +149,47 @@ public function testGetPolicyUnknownTable(): void $resolver->getPolicy($articles); } + public function testGetPolicyFromEntityClassString(): void + { + $resolver = new OrmResolver('TestApp'); + $policy = $resolver->getPolicy(Article::class); + $this->assertInstanceOf(ArticlePolicy::class, $policy); + } + + public function testGetPolicyFromTableClassString(): void + { + $resolver = new OrmResolver('TestApp'); + $policy = $resolver->getPolicy(ArticlesTable::class); + $this->assertInstanceOf(ArticlesTablePolicy::class, $policy); + } + + public function testGetPolicyFromUnrelatedClassString(): void + { + $resolver = new OrmResolver('TestApp'); + + $this->expectException(MissingPolicyException::class); + $resolver->getPolicy(TestService::class); + } + + public function testGetPolicyFromEntityNamespacedNonEntityClassString(): void + { + // FakeEntity lives under `\Model\Entity\` and has a matching FakeEntityPolicy, + // but is not an EntityInterface - interface discrimination must reject it + // rather than wrongly resolving the policy via namespace matching. + $resolver = new OrmResolver('TestApp'); + + $this->expectException(MissingPolicyException::class); + $resolver->getPolicy(FakeEntity::class); + } + + public function testGetPolicyFromNonClassString(): void + { + $resolver = new OrmResolver('TestApp'); + + $this->expectException(MissingPolicyException::class); + $resolver->getPolicy('NotAClassName'); + } + public function testGetPolicyViaDIC(): void { $container = new Container(); diff --git a/tests/test_app/TestApp/Model/Entity/FakeEntity.php b/tests/test_app/TestApp/Model/Entity/FakeEntity.php new file mode 100644 index 0000000..e7e1d3b --- /dev/null +++ b/tests/test_app/TestApp/Model/Entity/FakeEntity.php @@ -0,0 +1,15 @@ +