Skip to content

Commit da9a193

Browse files
authored
Fix prod-mode asserts, middleware exception routing, redirect GET guard (#330)
* Replace assert() in AuthorizationService with explicit exception throws The three assert() calls in performCheck(), getCanHandler() and getScopeHandler() are compiled out when PHP runs with zend.assertions=-1, the standard production setting. Under that configuration a missing policy method silently falls through to the callable invocation and throws a generic Error instead of MissingMethodException, and an invalid policy return value flows on and causes a downstream type error. Convert the three checks to explicit if/throw so the documented exceptions fire in both development and production. * Route policy exceptions through unauthorizedHandler in RequestAuthorizationMiddleware The call to AuthorizationService::canResult() sat outside the try/catch block, so any Authorization\Exception\Exception thrown from inside a RequestPolicy (e.g. MissingMethodException, a custom MissingIdentityException raised by canAccess(), or another policy-level failure) bypassed the configured unauthorizedHandler and bubbled out of the middleware unhandled. Move the canResult() call inside the try block to match the symmetry of AuthorizationMiddleware, so all policy-level exceptions are routed through the handler. Add a regression test using the Suppress handler. * Honor GET-only guard in CakeRedirectHandler::getUrl() The parent RedirectHandler::getUrl() guards the redirect query-param appendage with a GET method check, so POST/PUT/DELETE/PATCH unauthorized responses do not receive a useless `?redirect=` payload that clients cannot follow. The Cake-flavoured override dropped that guard and appended the query param for every method. Mirror the parent behavior and add a data-provider test covering all common non-GET methods. * Add regression test for invalid return type from policy method Covers the explicit-throw branch added in AuthorizationService::performCheck() when a policy method returns a value that is neither bool nor a ResultInterface. Adds a canInvalidReturnType() helper to ArticlePolicy returning a plain string and asserts the documented exception fires.
1 parent 21614bb commit da9a193

7 files changed

Lines changed: 104 additions & 15 deletions

File tree

src/AuthorizationService.php

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,12 @@ protected function performCheck(
9494
$handler = $this->getCanHandler($policy, $action);
9595
$result = $handler($user, $resource, ...$optionalArgs);
9696

97-
assert(
98-
is_bool($result) || $result instanceof ResultInterface,
99-
new Exception(sprintf(
97+
if (!is_bool($result) && !$result instanceof ResultInterface) {
98+
throw new Exception(sprintf(
10099
'Authorization check method must return `%s` or `bool`.',
101100
ResultInterface::class,
102-
)),
103-
);
101+
));
102+
}
104103

105104
return $result;
106105
}
@@ -138,10 +137,9 @@ protected function getCanHandler(mixed $policy, string $action): Closure
138137
{
139138
$method = 'can' . ucfirst($action);
140139

141-
assert(
142-
method_exists($policy, $method) || method_exists($policy, '__call'),
143-
new MissingMethodException([$method, $action, $policy::class]),
144-
);
140+
if (!method_exists($policy, $method) && !method_exists($policy, '__call')) {
141+
throw new MissingMethodException([$method, $action, $policy::class]);
142+
}
145143

146144
/** @phpstan-ignore callable.nonCallable */
147145
return [$policy, $method](...);
@@ -159,10 +157,9 @@ protected function getScopeHandler(mixed $policy, string $action): Closure
159157
{
160158
$method = 'scope' . ucfirst($action);
161159

162-
assert(
163-
method_exists($policy, $method) || method_exists($policy, '__call'),
164-
new MissingMethodException([$method, $action, $policy::class]),
165-
);
160+
if (!method_exists($policy, $method) && !method_exists($policy, '__call')) {
161+
throw new MissingMethodException([$method, $action, $policy::class]);
162+
}
166163

167164
/** @phpstan-ignore callable.nonCallable */
168165
return [$policy, $method](...);

src/Middleware/RequestAuthorizationMiddleware.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
9898
$service = $this->getServiceFromRequest($request);
9999
$identity = $request->getAttribute($this->getConfig('identityAttribute'));
100100

101-
$result = $service->canResult($identity, $this->getConfig('method'), $request);
102101
try {
102+
$result = $service->canResult($identity, $this->getConfig('method'), $request);
103103
if (!$result->getStatus()) {
104104
throw new ForbiddenException($result, [$this->getConfig('method'), $request->getRequestTarget()]);
105105
}

src/Middleware/UnauthorizedHandler/CakeRedirectHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public function __construct()
6868
protected function getUrl(ServerRequestInterface $request, array $options): string
6969
{
7070
$url = $options['url'];
71-
if ($options['queryParam'] !== null) {
71+
if ($options['queryParam'] !== null && $request->getMethod() === 'GET') {
7272
$uri = $request->getUri();
7373
$redirect = $uri->getPath();
7474
if ($uri->getQuery()) {

tests/TestCase/AuthorizationServiceTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
namespace Authorization\Test\TestCase;
1818

1919
use Authorization\AuthorizationService;
20+
use Authorization\Exception\Exception;
2021
use Authorization\IdentityDecorator;
2122
use Authorization\IdentityInterface;
2223
use Authorization\Policy\BeforePolicyInterface;
@@ -539,4 +540,22 @@ public function testMissingMethod(): void
539540

540541
$service->can($user, 'disable', $entity);
541542
}
543+
544+
public function testCanThrowsWhenPolicyReturnsInvalidType(): void
545+
{
546+
$resolver = new MapResolver([
547+
Article::class => ArticlePolicy::class,
548+
]);
549+
$service = new AuthorizationService($resolver);
550+
$user = new IdentityDecorator($service, [
551+
'role' => 'admin',
552+
]);
553+
554+
$this->expectException(Exception::class);
555+
$this->expectExceptionMessage(
556+
'Authorization check method must return `Authorization\Policy\ResultInterface` or `bool`.',
557+
);
558+
559+
$service->can($user, 'invalidReturnType', new Article());
560+
}
542561
}

tests/TestCase/Middleware/RequestAuthorizationMiddlewareTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,32 @@ public function testUnauthorizedHandlerSuppress(): void
146146

147147
$this->assertSame(200, $result->getStatusCode());
148148
}
149+
150+
public function testPolicyExceptionRoutedThroughUnauthorizedHandler(): void
151+
{
152+
$request = (new ServerRequest([
153+
'url' => '/articles/index',
154+
]))
155+
->withParam('action', 'index')
156+
->withParam('controller', 'Articles');
157+
158+
$handler = new TestRequestHandler();
159+
160+
$resolver = new MapResolver([
161+
ServerRequest::class => new RequestPolicy(),
162+
]);
163+
164+
$authService = new AuthorizationService($resolver);
165+
$request = $request->withAttribute('authorization', $authService);
166+
167+
// `doesNotExist` triggers MissingMethodException inside canResult();
168+
// the middleware should route it through the configured handler instead of letting it bubble.
169+
$middleware = new RequestAuthorizationMiddleware([
170+
'method' => 'doesNotExist',
171+
'unauthorizedHandler' => 'Suppress',
172+
]);
173+
$result = $middleware->process($request, $handler);
174+
175+
$this->assertSame(200, $result->getStatusCode());
176+
}
149177
}

tests/TestCase/Middleware/UnauthorizedHandler/CakeRedirectHandlerTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Cake\Http\ServerRequestFactory;
2323
use Cake\Routing\Router;
2424
use Cake\TestSuite\TestCase;
25+
use PHPUnit\Framework\Attributes\DataProvider;
2526

2627
class CakeRedirectHandlerTest extends TestCase
2728
{
@@ -150,4 +151,39 @@ public function testHandleRedirectWithBasePath(): void
150151
$response->getHeaderLine('Location'),
151152
);
152153
}
154+
155+
public static function httpMethodProvider(): array
156+
{
157+
return [
158+
['POST'],
159+
['PUT'],
160+
['DELETE'],
161+
['PATCH'],
162+
['OPTIONS'],
163+
['HEAD'],
164+
];
165+
}
166+
167+
#[DataProvider('httpMethodProvider')]
168+
public function testHandleRedirectionIgnoreNonIdempotentMethods(string $method): void
169+
{
170+
$handler = new CakeRedirectHandler();
171+
172+
$exception = new Exception();
173+
$request = ServerRequestFactory::fromGlobals(
174+
[
175+
'REQUEST_METHOD' => $method,
176+
'REQUEST_URI' => '/admin/dashboard',
177+
],
178+
);
179+
180+
$response = $handler->handle($exception, $request, [
181+
'exceptions' => [
182+
Exception::class,
183+
],
184+
]);
185+
186+
$this->assertSame(302, $response->getStatusCode());
187+
$this->assertSame('/login', $response->getHeaderLine('Location'));
188+
}
153189
}

tests/test_app/TestApp/Policy/ArticlePolicy.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,13 @@ public function canWithInjectedService($user, Article $article)
141141
{
142142
return $this->service->serviceLogic();
143143
}
144+
145+
/**
146+
* Returns an invalid type (not bool, not ResultInterface) to exercise the
147+
* defensive type guard in AuthorizationService::performCheck().
148+
*/
149+
public function canInvalidReturnType($user, Article $article): string
150+
{
151+
return 'not a bool nor a Result';
152+
}
144153
}

0 commit comments

Comments
 (0)