Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/Policy/MapResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
40 changes: 26 additions & 14 deletions src/Policy/OrmResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,51 +79,59 @@ 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();
if (!$repo instanceof RepositoryInterface) {
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);
}
Expand Down
21 changes: 20 additions & 1 deletion tests/TestCase/Policy/MapResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
43 changes: 43 additions & 0 deletions tests/TestCase/Policy/OrmResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions tests/test_app/TestApp/Model/Entity/FakeEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace TestApp\Model\Entity;

/**
* Lives under the `\Model\Entity\` namespace but is NOT an EntityInterface.
*
* Used to prove the resolver discriminates by interface, not by namespace
* substring: a matching FakeEntityPolicy exists, so a substring-based lookup
* would wrongly resolve it, while the interface check rejects it.
*/
class FakeEntity
{
}
14 changes: 14 additions & 0 deletions tests/test_app/TestApp/Policy/FakeEntityPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);

namespace TestApp\Policy;

/**
* A policy that conventionally matches TestApp\Model\Entity\FakeEntity.
*
* Its existence is the trap: substring-based resolution would return it for a
* non-entity class. The interface-based resolver must NOT reach this.
*/
class FakeEntityPolicy
{
}
Loading