diff --git a/app/Config/Routing.php b/app/Config/Routing.php index 3005543a9e79..5aeee51d212d 100644 --- a/app/Config/Routing.php +++ b/app/Config/Routing.php @@ -96,6 +96,15 @@ class Routing extends BaseRouting */ public bool $autoRoute = false; + /** + * If TRUE, the system will look for attributes on controller + * class and methods that can run before and after the + * controller/method. + * + * If FALSE, will ignore any attributes. + */ + public bool $useControllerAttributes = true; + /** * For Defined Routes. * If TRUE, will enable the use of the 'prioritize' option diff --git a/composer.json b/composer.json index 47efbda75718..b4e38c92e38b 100644 --- a/composer.json +++ b/composer.json @@ -117,10 +117,10 @@ "phpstan:baseline": [ "bash -c \"rm -rf utils/phpstan-baseline/*.neon\"", "bash -c \"touch utils/phpstan-baseline/loader.neon\"", - "phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon", + "phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon --memory-limit=512M", "split-phpstan-baseline utils/phpstan-baseline/loader.neon" ], - "phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi", + "phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi --memory-limit=512M", "sa": "@analyze", "style": "@cs-fix", "test": "phpunit" diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 26a64575cc98..2219449e71f3 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -25,6 +25,7 @@ use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponsableInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\URI; @@ -460,8 +461,12 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache $returned = $this->startController(); + // If startController returned a Response (from an attribute or Closure), use it + if ($returned instanceof ResponseInterface) { + $this->gatherOutput($cacheConfig, $returned); + } // Closure controller has run in startController(). - if (! is_callable($this->controller)) { + elseif (! is_callable($this->controller)) { $controller = $this->createController(); if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) { @@ -497,6 +502,13 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache } } + // Execute controller attributes' after() methods AFTER framework filters + if ((config('Routing')->useControllerAttributes ?? true) === true) { + $this->benchmark->start('route_attributes_after'); + $this->response = $this->router->executeAfterAttributes($this->request, $this->response); + $this->benchmark->stop('route_attributes_after'); + } + // Skip unnecessary processing for special Responses. if ( ! $this->response instanceof DownloadResponse @@ -855,6 +867,27 @@ protected function startController() throw PageNotFoundException::forControllerNotFound($this->controller, $this->method); } + // Execute route attributes' before() methods + // This runs after routing/validation but BEFORE expensive controller instantiation + if ((config('Routing')->useControllerAttributes ?? true) === true) { + $this->benchmark->start('route_attributes_before'); + $attributeResponse = $this->router->executeBeforeAttributes($this->request); + $this->benchmark->stop('route_attributes_before'); + + // If attribute returns a Response, short-circuit + if ($attributeResponse instanceof ResponseInterface) { + $this->benchmark->stop('controller_constructor'); + $this->benchmark->stop('controller'); + + return $attributeResponse; + } + + // If attribute returns a modified Request, use it + if ($attributeResponse instanceof RequestInterface) { + $this->request = $attributeResponse; + } + } + return null; } diff --git a/system/Config/Routing.php b/system/Config/Routing.php index 6999ad3c5b3c..4db55b3aa0c2 100644 --- a/system/Config/Routing.php +++ b/system/Config/Routing.php @@ -96,6 +96,15 @@ class Routing extends BaseConfig */ public bool $autoRoute = false; + /** + * If TRUE, the system will look for attributes on controller + * class and methods that can run before and after the + * controller/method. + * + * If FALSE, will ignore any attributes. + */ + public bool $useControllerAttributes = true; + /** * For Defined Routes. * If TRUE, will enable the use of the 'prioritize' option diff --git a/system/Router/Attributes/Cache.php b/system/Router/Attributes/Cache.php new file mode 100644 index 000000000000..5bf083f5fe4a --- /dev/null +++ b/system/Router/Attributes/Cache.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use Attribute; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Cache Attribute + * + * Caches the response of a controller method at the server level for a specified duration. + * This is server-side caching to avoid expensive operations, not browser-level caching. + * + * Usage: + * ```php + * #[Cache(for: 3600)] // Cache for 1 hour + * #[Cache(for: 300, key: 'custom_key')] // Cache with custom key + * ``` + * + * Limitations: + * - Only caches GET requests; POST, PUT, DELETE, and other methods are ignored + * - Streaming responses or file downloads may not cache properly + * - Cache key includes HTTP method, path, query string, and possibly user_id(), but not request headers + * - Does not automatically invalidate related cache entries + * - Cookies set in the response are cached and reused for all subsequent requests + * - Large responses may impact cache storage performance + * - Browser Cache-Control headers do not affect server-side caching behavior + * + * Security Considerations: + * - Ensure cache backend is properly secured and not accessible publicly + * - Be aware that authorization checks happen before cache lookup + */ +#[Attribute(Attribute::TARGET_METHOD)] +class Cache implements RouteAttributeInterface +{ + public function __construct( + public int $for = 3600, + public ?string $key = null, + ) { + } + + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null + { + // Only cache GET requests + if ($request->getMethod() !== 'GET') { + return null; + } + + // Check cache before controller execution + $cacheKey = $this->key ?? $this->generateCacheKey($request); + + $cached = cache($cacheKey); + // Validate cached data structure + if ($cached !== null && (is_array($cached) && isset($cached['body'], $cached['headers'], $cached['status']))) { + $response = service('response'); + $response->setBody($cached['body']); + $response->setStatusCode($cached['status']); + // Mark response as served from cache to prevent re-caching + $response->setHeader('X-Cached-Response', 'true'); + + // Restore headers from cached array of header name => value strings + foreach ($cached['headers'] as $name => $value) { + $response->setHeader($name, $value); + } + $response->setHeader('Age', (string) (time() - ($cached['timestamp'] ?? time()))); + + return $response; + } + + return null; // Continue to controller + } + + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + // Don't re-cache if response was already served from cache + if ($response->hasHeader('X-Cached-Response')) { + // Remove the marker header before sending response + $response->removeHeader('X-Cached-Response'); + + return null; + } + + // Only cache GET requests + if ($request->getMethod() !== 'GET') { + return null; + } + + $cacheKey = $this->key ?? $this->generateCacheKey($request); + + // Convert Header objects to strings for caching + $headers = []; + + foreach ($response->headers() as $name => $header) { + // Handle both single Header and array of Headers + if (is_array($header)) { + // Multiple headers with same name + $values = []; + + foreach ($header as $h) { + $values[] = $h->getValueLine(); + } + $headers[$name] = implode(', ', $values); + } else { + // Single header + $headers[$name] = $header->getValueLine(); + } + } + + $data = [ + 'body' => $response->getBody(), + 'headers' => $headers, + 'status' => $response->getStatusCode(), + 'timestamp' => time(), + ]; + + cache()->save($cacheKey, $data, $this->for); + + return $response; + } + + protected function generateCacheKey(RequestInterface $request): string + { + return 'route_cache_' . hash( + 'xxh128', + $request->getMethod() . + $request->getUri()->getPath() . + $request->getUri()->getQuery() . + (function_exists('user_id') ? user_id() : ''), + ); + } +} diff --git a/system/Router/Attributes/Filter.php b/system/Router/Attributes/Filter.php new file mode 100644 index 000000000000..33794e1711bf --- /dev/null +++ b/system/Router/Attributes/Filter.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use Attribute; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Filter Attribute + * + * Applies CodeIgniter filters to controller classes or methods. Filters can perform + * operations before or after controller execution, such as authentication, CSRF protection, + * rate limiting, or request/response manipulation. + * + * Limitations: + * - Filter must be registered in Config\Filters.php or won't be found + * - Does not validate filter existence at attribute definition time + * - Cannot conditionally apply filters based on runtime conditions + * - Class-level filters cannot be overridden or disabled for specific methods + * + * Security Considerations: + * - Filters run in the order specified; authentication should typically come first + * - Don't rely solely on filters for critical security; validate in controllers too + * - Ensure sensitive filters are registered as globals if they should apply site-wide + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Filter implements RouteAttributeInterface +{ + public function __construct( + public string $by, + public array $having = [], + ) { + } + + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null + { + // Filters are handled by the filter system via getFilters() + // No processing needed here + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + return null; + } + + public function getFilters(): array + { + if ($this->having === []) { + return [$this->by]; + } + + return [$this->by => $this->having]; + } +} diff --git a/system/Router/Attributes/Restrict.php b/system/Router/Attributes/Restrict.php new file mode 100644 index 000000000000..e8738befd31e --- /dev/null +++ b/system/Router/Attributes/Restrict.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use Attribute; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Restrict Attribute + * + * Restricts access to controller methods or entire controllers based on environment, + * hostname, or subdomain conditions. Throws PageNotFoundException when restrictions + * are not met. + * + * Limitations: + * - Throws PageNotFoundException (404) for all restriction failures + * - Cannot provide custom error messages or HTTP status codes + * - Subdomain detection may not work correctly behind proxies without proper configuration + * - Does not support wildcard or regex patterns for hostnames + * - Cannot restrict based on request headers, IP addresses, or user authentication + * + * Security Considerations: + * - Environment checks rely on the ENVIRONMENT constant being correctly set + * - Hostname restrictions can be bypassed if Host header is not validated at web server level + * - Should not be used as the sole security mechanism for sensitive operations + * - Consider additional authorization checks for critical endpoints + * - Does not prevent direct access if routes are exposed through other means + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Restrict implements RouteAttributeInterface +{ + private const TWO_PART_TLDS = [ + 'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk', + 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au', + 'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp', + 'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz', + 'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in', + 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', + 'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg', + 'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za', + 'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr', + 'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th', + 'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my', + 'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx', + 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br', + 'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il', + 'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id', + 'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk', + 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw', + 'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa', + 'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae', + 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr', + 'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke', + 'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng', + 'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk', + 'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg', + 'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy', + 'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk', + 'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd', + 'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar', + 'gob.cl', + ]; + + public function __construct( + public array|string|null $environment = null, + public array|string|null $hostname = null, + public array|string|null $subdomain = null, + ) { + } + + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null + { + $this->checkEnvironment(); + $this->checkHostname($request); + $this->checkSubdomain($request); + + return null; // Continue normal execution + } + + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + return null; // No post-processing needed + } + + protected function checkEnvironment(): void + { + if ($this->environment === null || $this->environment === []) { + return; + } + + $currentEnv = ENVIRONMENT; + $allowed = []; + $denied = []; + + foreach ((array) $this->environment as $env) { + if (str_starts_with($env, '!')) { + $denied[] = substr($env, 1); + } else { + $allowed[] = $env; + } + } + + // Check denied environments first (explicit deny takes precedence) + if ($denied !== [] && in_array($currentEnv, $denied, true)) { + throw new PageNotFoundException('Access denied: Current environment is blocked.'); + } + + // If allowed list exists, current env must be in it + // If no allowed list (only denials), then all non-denied envs are allowed + if ($allowed !== [] && ! in_array($currentEnv, $allowed, true)) { + throw new PageNotFoundException('Access denied: Current environment is not allowed.'); + } + } + + private function checkHostname(RequestInterface $request): void + { + if ($this->hostname === null || $this->hostname === []) { + return; + } + + $currentHost = strtolower($request->getUri()->getHost()); + $allowedHosts = array_map('strtolower', (array) $this->hostname); + + if (! in_array($currentHost, $allowedHosts, true)) { + throw new PageNotFoundException('Access denied: Host is not allowed.'); + } + } + + private function checkSubdomain(RequestInterface $request): void + { + if ($this->subdomain === null || $this->subdomain === []) { + return; + } + + $currentSubdomain = $this->getSubdomain($request); + $allowedSubdomains = array_map('strtolower', (array) $this->subdomain); + + // If no subdomain exists but one is required + if ($currentSubdomain === '') { + throw new PageNotFoundException('Access denied: Subdomain required'); + } + + // Check if the current subdomain is allowed + if (! in_array($currentSubdomain, $allowedSubdomains, true)) { + throw new PageNotFoundException('Access denied: subdomain is blocked.'); + } + } + + private function getSubdomain(RequestInterface $request): string + { + $host = strtolower($request->getUri()->getHost()); + + // Handle localhost and IP addresses - they don't have subdomains + if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { + return ''; + } + + $parts = explode('.', $host); + $partCount = count($parts); + + // Need at least 3 parts for a subdomain (subdomain.domain.tld) + // e.g., api.example.com + if ($partCount < 3) { + return ''; + } + // Check if we have a two-part TLD (e.g., co.uk, com.au) + $lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1]; + if (in_array($lastTwoParts, self::TWO_PART_TLDS, true)) { + // For two-part TLD, need at least 4 parts for subdomain + // e.g., api.example.co.uk (4 parts) + if ($partCount < 4) { + return ''; // No subdomain, just domain.co.uk + } + + // Remove the two-part TLD and domain name (last 3 parts) + // e.g., admin.api.example.co.uk -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 3)); + } + + // Standard TLD: Remove TLD and domain (last 2 parts) + // e.g., admin.api.example.com -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 2)); + } +} diff --git a/system/Router/Attributes/RouteAttributeInterface.php b/system/Router/Attributes/RouteAttributeInterface.php new file mode 100644 index 000000000000..ac735ef9a854 --- /dev/null +++ b/system/Router/Attributes/RouteAttributeInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +interface RouteAttributeInterface +{ + /** + * Process the attribute before the controller is executed. + * + * @return RequestInterface|ResponseInterface|null + * Return RequestInterface to replace the request + * Return ResponseInterface to short-circuit and send response + * Return null to continue normal execution + */ + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null; + + /** + * Process the attribute after the controller is executed. + * + * @return ResponseInterface|null + * Return ResponseInterface to replace the response + * Return null to use the existing response + */ + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface; +} diff --git a/system/Router/Router.php b/system/Router/Router.php index eb75e33adc3e..f2b291e58bbb 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -19,11 +19,16 @@ use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\Attributes\Filter; +use CodeIgniter\Router\Attributes\RouteAttributeInterface; use CodeIgniter\Router\Exceptions\RouterException; use Config\App; use Config\Feature; use Config\Routing; +use ReflectionClass; +use Throwable; /** * Request router. @@ -131,6 +136,13 @@ class Router implements RouterInterface protected ?AutoRouterInterface $autoRouter = null; + /** + * Route attributes collected during routing for the current route. + * + * @var array{class: list, method: list} + */ + protected array $routeAttributes = ['class' => [], 'method' => []]; + /** * Permitted URI chars * @@ -215,6 +227,8 @@ public function handle(?string $uri = null) $this->filtersInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]); } + $this->processRouteAttributes(); + return $this->controller; } @@ -230,6 +244,8 @@ public function handle(?string $uri = null) // Checks auto routes $this->autoRoute($uri); + $this->processRouteAttributes(); + return $this->controllerName(); } @@ -240,7 +256,18 @@ public function handle(?string $uri = null) */ public function getFilters(): array { - return $this->filtersInfo; + $filters = $this->filtersInfo; + + // Check for attribute-based filters + foreach ($this->routeAttributes as $attributes) { + foreach ($attributes as $attribute) { + if ($attribute instanceof Filter) { + $filters = array_merge($filters, $attribute->getFilters()); + } + } + } + + return $filters; } /** @@ -744,4 +771,126 @@ private function checkDisallowedChars(string $uri): void } } } + + /** + * Extracts PHP attributes from the resolved controller and method. + */ + private function processRouteAttributes(): void + { + $this->routeAttributes = ['class' => [], 'method' => []]; + + // Skip if controller attributes are disabled in config + if (config('routing')->useControllerAttributes === false) { + return; + } + + // Skip if controller is a Closure + if ($this->controller instanceof Closure) { + return; + } + + if (! class_exists($this->controller)) { + return; + } + + $reflectionClass = new ReflectionClass($this->controller); + + // Process class-level attributes + foreach ($reflectionClass->getAttributes() as $attribute) { + try { + $instance = $attribute->newInstance(); + + if ($instance instanceof RouteAttributeInterface) { + $this->routeAttributes['class'][] = $instance; + } + } catch (Throwable) { + log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName()); + } + } + + if ($this->method === '' || $this->method === null) { + return; + } + + // Process method-level attributes + if ($reflectionClass->hasMethod($this->method)) { + $reflectionMethod = $reflectionClass->getMethod($this->method); + + foreach ($reflectionMethod->getAttributes() as $attribute) { + try { + $instance = $attribute->newInstance(); + + if ($instance instanceof RouteAttributeInterface) { + $this->routeAttributes['method'][] = $instance; + } + } catch (Throwable) { + // Skip attributes that fail to instantiate + log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName()); + } + } + } + } + + /** + * Execute beforeController() on all route attributes. + * Called by CodeIgniter before controller execution. + */ + public function executeBeforeAttributes(RequestInterface $request): RequestInterface|ResponseInterface|null + { + // Process class-level attributes first, then method-level + foreach (['class', 'method'] as $level) { + foreach ($this->routeAttributes[$level] as $attribute) { + if (! $attribute instanceof RouteAttributeInterface) { + continue; + } + + $result = $attribute->before($request); + + // If attribute returns a Response, short-circuit + if ($result instanceof ResponseInterface) { + return $result; + } + + // If attribute returns a Request, use it + if ($result instanceof RequestInterface) { + $request = $result; + } + } + } + + return $request; + } + + /** + * Execute afterController() on all route attributes. + * Called by CodeIgniter after controller execution. + */ + public function executeAfterAttributes(RequestInterface $request, ResponseInterface $response): ResponseInterface + { + // Process in reverse order: method-level first, then class-level + foreach (array_reverse(['class', 'method']) as $level) { + foreach ($this->routeAttributes[$level] as $attribute) { + if ($attribute instanceof RouteAttributeInterface) { + $result = $attribute->after($request, $response); + + if ($result instanceof ResponseInterface) { + $response = $result; + } + } + } + } + + return $response; + } + + /** + * Returns the route attributes collected during routing + * for the current route. + * + * @return array{class: list, method: list} + */ + public function getRouteAttributes(): array + { + return $this->routeAttributes; + } } diff --git a/tests/_support/Router/Controllers/AttributeController.php b/tests/_support/Router/Controllers/AttributeController.php new file mode 100644 index 000000000000..0c43404a2a8e --- /dev/null +++ b/tests/_support/Router/Controllers/AttributeController.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Router\Controllers; + +use CodeIgniter\Controller; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\Attributes\Cache; +use CodeIgniter\Router\Attributes\Filter; +use CodeIgniter\Router\Attributes\Restrict; + +class AttributeController extends Controller +{ + /** + * Test method with Cache attribute + */ + #[Cache(for: 60)] + public function cached(): ResponseInterface + { + return $this->response->setBody('Cached content at ' . time()); + } + + /** + * Test method with Filter attribute + */ + #[Filter(by: 'testAttributeFilter')] + public function filtered(): ResponseInterface + { + $body = $this->request->getBody(); + + return $this->response->setBody('Filtered: ' . $body); + } + + /** + * Test method with Restrict attribute (environment) + */ + #[Restrict(environment: ENVIRONMENT)] + public function restricted(): ResponseInterface + { + return $this->response->setBody('Access granted'); + } + + /** + * Test method with multiple attributes + */ + #[Filter(by: 'testAttributeFilter')] + #[Restrict(environment: ENVIRONMENT)] + public function multipleAttributes(): ResponseInterface + { + $body = $this->request->getBody(); + + return $this->response->setBody('Multiple: ' . $body); + } + + /** + * Test method that should be restricted + */ + #[Restrict(environment: 'production')] + public function shouldBeRestricted(): ResponseInterface + { + return $this->response->setBody('Should not see this'); + } + + /** + * Test method with custom cache key + */ + #[Cache(for: 60, key: 'custom_cache_key')] + public function customCacheKey(): ResponseInterface + { + return $this->response->setBody('Custom key content at ' . time()); + } + + /** + * Simple method with no attributes + */ + public function noAttributes(): ResponseInterface + { + return $this->response->setBody('No attributes'); + } +} diff --git a/tests/_support/Router/Filters/TestAttributeFilter.php b/tests/_support/Router/Filters/TestAttributeFilter.php new file mode 100644 index 000000000000..dbafc230ec3e --- /dev/null +++ b/tests/_support/Router/Filters/TestAttributeFilter.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Router\Filters; + +use CodeIgniter\Filters\FilterInterface; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +class TestAttributeFilter implements FilterInterface +{ + public function before(RequestInterface $request, $arguments = null) + { + // Modify request body to indicate filter ran + $request->setBody('before_filter_ran:'); + + return $request; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // Append to response body to indicate filter ran + $body = $response->getBody(); + $response->setBody($body . ':after_filter_ran'); + + return $response; + } +} diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index a4216391b7ee..e3acab514cea 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter; use App\Controllers\Home; +use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\Debug\Timer; use CodeIgniter\Exceptions\PageNotFoundException; @@ -36,6 +37,7 @@ use PHPUnit\Framework\Attributes\WithoutErrorHandler; use Tests\Support\Filters\Customfilter; use Tests\Support\Filters\RedirectFilter; +use Tests\Support\Router\Filters\TestAttributeFilter; /** * @internal @@ -954,6 +956,15 @@ public function testStartControllerPermitsInvoke(): void { $this->setPrivateProperty($this->codeigniter, 'benchmark', new Timer()); $this->setPrivateProperty($this->codeigniter, 'controller', '\\' . Home::class); + + // Set up the request and router + $request = service('incomingrequest'); + $this->setPrivateProperty($this->codeigniter, 'request', $request); + + $routes = service('routes'); + $router = service('router', $routes, $request); + $this->setPrivateProperty($this->codeigniter, 'router', $router); + $startController = self::getPrivateMethodInvoker($this->codeigniter, 'startController'); $this->setPrivateProperty($this->codeigniter, 'method', '__invoke'); @@ -962,4 +973,261 @@ public function testStartControllerPermitsInvoke(): void // No PageNotFoundException $this->assertTrue(true); } + + public function testRouteAttributeCacheIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/cached']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/cached'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + + // Clear cache before test + cache()->clean(); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/cached', '\Tests\Support\Router\Controllers\AttributeController::cached'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + // First request - should cache + ob_start(); + $this->codeigniter->run(); + $output1 = ob_get_clean(); + + $this->assertStringContainsString('Cached content at', (string) $output1); + + // Extract timestamp from first response + preg_match('/Cached content at (\d+)/', (string) $output1, $matches1); + $time1 = $matches1[1] ?? null; + + // Wait a moment to ensure time would be different if not cached + sleep(1); + + // Second request - should return cached version with same timestamp + $this->resetServices(); + $_SERVER['argv'] = ['index.php', 'attribute/cached']; + $_SERVER['argc'] = 2; + Services::superglobals()->setServer('REQUEST_URI', '/attribute/cached'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + $this->codeigniter = new MockCodeIgniter(new App()); + + $routes = service('routes'); + $routes->get('attribute/cached', '\Tests\Support\Router\Controllers\AttributeController::cached'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output2 = ob_get_clean(); + + preg_match('/Cached content at (\d+)/', (string) $output2, $matches2); + $time2 = $matches2[1] ?? null; + + // Timestamps should be EXACTLY the same (cached response) + $this->assertSame($time1, $time2, 'Expected cached response with identical timestamp'); + + // Clear cache after test + cache()->clean(); + } + + public function testRouteAttributeFilterIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/filtered']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/filtered'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Register the test filter + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/filtered', '\Tests\Support\Router\Controllers\AttributeController::filtered'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Verify filter ran before (modified request body) and after (appended to response) + $this->assertStringContainsString('Filtered: before_filter_ran:', (string) $output); + $this->assertStringContainsString(':after_filter_ran', (string) $output); + } + + public function testRouteAttributeRestrictIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/restricted']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/restricted'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/restricted', '\Tests\Support\Router\Controllers\AttributeController::restricted'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Should allow access since we're in the current ENVIRONMENT + $this->assertStringContainsString('Access granted', (string) $output); + } + + public function testRouteAttributeRestrictThrowsException(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/restricted']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/shouldBeRestricted'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/shouldBeRestricted', '\Tests\Support\Router\Controllers\AttributeController::shouldBeRestricted'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + // Should throw PageNotFoundException because we're not in 'production' + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is not allowed.'); + + $this->codeigniter->run(); + } + + public function testRouteAttributeMultipleAttributesIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/multiple']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/multiple'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Register the test filter + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/multiple', '\Tests\Support\Router\Controllers\AttributeController::multipleAttributes'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Verify both Restrict and Filter attributes worked + $this->assertStringContainsString('Multiple: before_filter_ran:', (string) $output); + $this->assertStringContainsString(':after_filter_ran', (string) $output); + } + + public function testRouteAttributeNoAttributesIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/none']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/none'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/none', '\Tests\Support\Router\Controllers\AttributeController::noAttributes'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Should work normally with no attribute processing + $this->assertStringContainsString('No attributes', (string) $output); + } + + public function testRouteAttributeCustomCacheKeyIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/customkey']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/customkey'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + + // Clear cache before test + cache()->clean(); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/customkey', '\Tests\Support\Router\Controllers\AttributeController::customCacheKey'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + // First request + ob_start(); + $this->codeigniter->run(); + ob_get_clean(); + + // Verify custom cache key was used + $cached = cache('custom_cache_key'); + $this->assertNotNull($cached); + $this->assertIsArray($cached); + $this->assertArrayHasKey('body', $cached); + $this->assertStringContainsString('Custom key content at', (string) $cached['body']); + + // Clear cache after test + cache()->clean(); + } + + public function testRouteAttributesDisabledInConfig(): void + { + Services::superglobals()->setServer('REQUEST_URI', '/attribute/filtered'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + + // Disable route attributes in config BEFORE creating CodeIgniter instance + $routing = config('routing'); + $routing->useControllerAttributes = false; + Factories::injectMock('config', 'routing', $routing); + + // Register the test filter (even though attributes are disabled, + // we need it registered to avoid FilterException) + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + $routes = service('routes'); + $routes->setAutoRoute(false); + + // We're testing that a route defined normally will work, + // but the attributes on the controller method won't be processed + $routes->get('attribute/filtered', '\Tests\Support\Router\Controllers\AttributeController::filtered'); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $config = new App(); + $codeigniter = new MockCodeIgniter($config); + + ob_start(); + $codeigniter->run($routes); + $output = ob_get_clean(); + + // When useRouteAttributes is false, the filter attributes should NOT be processed + // So the filter should not have run + $this->assertStringNotContainsString('before_filter_ran', (string) $output); + $this->assertStringNotContainsString('after_filter_ran', (string) $output); + // But the controller method should still execute + $this->assertStringContainsString('Filtered', (string) $output); + } } diff --git a/tests/system/Router/Attributes/CacheTest.php b/tests/system/Router/Attributes/CacheTest.php new file mode 100644 index 000000000000..2eb114c9cd87 --- /dev/null +++ b/tests/system/Router/Attributes/CacheTest.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class CacheTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + // Clear cache before each test + cache()->clean(); + } + + public function testConstructorDefaults(): void + { + $cache = new Cache(); + + $this->assertSame(3600, $cache->for); + $this->assertNull($cache->key); + } + + public function testConstructorCustomValues(): void + { + $cache = new Cache(for: 300, key: 'custom_key'); + + $this->assertSame(300, $cache->for); + $this->assertSame('custom_key', $cache->key); + } + + public function testBeforeReturnsNullForNonGetRequest(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('POST', '/test'); + + $result = $cache->before($request); + + $this->assertNull($result); + } + + public function testBeforeReturnsCachedResponseWhenFound(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('GET', '/test'); + + // Manually cache a response + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + $cachedData = [ + 'body' => 'Cached content', + 'status' => 200, + 'headers' => ['Content-Type' => 'text/html'], + 'timestamp' => time() - 10, + ]; + cache()->save($cacheKey, $cachedData, 3600); + + $result = $cache->before($request); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Cached content', $result->getBody()); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('text/html', $result->getHeaderLine('Content-Type')); + $this->assertSame('10', $result->getHeaderLine('Age')); + } + + public function testBeforeReturnsNullForInvalidCacheData(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('GET', '/test'); + + // Cache invalid data + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + cache()->save($cacheKey, 'invalid data', 3600); + + $result = $cache->before($request); + + $this->assertNull($result); + } + + public function testBeforeReturnsNullForIncompleteCacheData(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('GET', '/test'); + + // Cache incomplete data (missing 'headers' key) + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + cache()->save($cacheKey, ['body' => 'test', 'status' => 200], 3600); + + $result = $cache->before($request); + + $this->assertNull($result); + } + + public function testBeforeUsesCustomCacheKey(): void + { + $cache = new Cache(key: 'my_custom_key'); + $request = $this->createMockRequest('GET', '/test'); + + // Cache with custom key + $cachedData = [ + 'body' => 'Custom cached content', + 'status' => 200, + 'headers' => [], + 'timestamp' => time(), + ]; + cache()->save('my_custom_key', $cachedData, 3600); + + $result = $cache->before($request); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Custom cached content', $result->getBody()); + } + + public function testAfterReturnsNullForNonGetRequest(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('POST', '/test'); + $response = Services::response(); + + $result = $cache->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testAfterCachesGetRequestResponse(): void + { + $cache = new Cache(for: 300); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + $response->setBody('Test content'); + $response->setStatusCode(200); + $response->setHeader('Content-Type', 'text/plain'); + + $result = $cache->after($request, $response); + + $this->assertSame($response, $result); + + // Verify cache was saved + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + $cached = cache($cacheKey); + + $this->assertIsArray($cached); + $this->assertSame('Test content', $cached['body']); + $this->assertSame(200, $cached['status']); + $this->assertArrayHasKey('timestamp', $cached); + } + + public function testAfterUsesCustomCacheKey(): void + { + $cache = new Cache(key: 'another_custom_key'); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + $response->setBody('Custom key content'); + + $cache->after($request, $response); + + // Verify cache was saved with custom key + $cached = cache('another_custom_key'); + + $this->assertIsArray($cached); + $this->assertSame('Custom key content', $cached['body']); + } + + public function testGenerateCacheKeyIncludesMethodPathAndQuery(): void + { + $cache = new Cache(); + $request1 = $this->createMockRequest('GET', '/test', 'foo=bar'); + $request2 = $this->createMockRequest('GET', '/test', 'foo=baz'); + + $key1 = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request1); + $key2 = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request2); + + $this->assertNotSame($key1, $key2); + $this->assertStringStartsWith('route_cache_', $key1); + } + + private function createMockRequest(string $method, string $path, string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + $uri = new SiteURI($config, 'http://example.com' . $path . ($query !== '' ? '?' . $query : '')); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + $request->setMethod($method); + + return $request; + } +} diff --git a/tests/system/Router/Attributes/FilterTest.php b/tests/system/Router/Attributes/FilterTest.php new file mode 100644 index 000000000000..8a230c805287 --- /dev/null +++ b/tests/system/Router/Attributes/FilterTest.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class FilterTest extends CIUnitTestCase +{ + public function testConstructorWithFilterNameOnly(): void + { + $filter = new Filter(by: 'auth'); + + $this->assertSame('auth', $filter->by); + $this->assertSame([], $filter->having); + } + + public function testConstructorWithFilterNameAndArguments(): void + { + $filter = new Filter(by: 'auth', having: ['admin', 'editor']); + + $this->assertSame('auth', $filter->by); + $this->assertSame(['admin', 'editor'], $filter->having); + } + + public function testConstructorWithEmptyHaving(): void + { + $filter = new Filter(by: 'throttle', having: []); + + $this->assertSame('throttle', $filter->by); + $this->assertSame([], $filter->having); + } + + public function testBeforeReturnsNull(): void + { + $filter = new Filter(by: 'auth'); + $request = $this->createMockRequest('GET', '/test'); + + $result = $filter->before($request); + + $this->assertNull($result); + } + + public function testAfterReturnsNull(): void + { + $filter = new Filter(by: 'toolbar'); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + + $result = $filter->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testGetFiltersReturnsArrayWithFilterNameOnly(): void + { + $filter = new Filter(by: 'csrf'); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('csrf', $filters[0]); + } + + public function testGetFiltersReturnsArrayWithFilterNameAndArguments(): void + { + $filter = new Filter(by: 'auth', having: ['admin']); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertArrayHasKey('auth', $filters); + $this->assertSame(['admin'], $filters['auth']); + } + + public function testGetFiltersReturnsArrayWithMultipleArguments(): void + { + $filter = new Filter(by: 'permission', having: ['posts.edit', 'posts.delete']); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertArrayHasKey('permission', $filters); + $this->assertSame(['posts.edit', 'posts.delete'], $filters['permission']); + } + + public function testGetFiltersWithEmptyHavingReturnsSimpleArray(): void + { + $filter = new Filter(by: 'cors', having: []); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('cors', $filters[0]); + } + + public function testMultipleFiltersCanBeCreated(): void + { + $filter1 = new Filter(by: 'auth'); + $filter2 = new Filter(by: 'csrf'); + $filter3 = new Filter(by: 'throttle', having: ['60', '1']); + + $this->assertSame('auth', $filter1->by); + $this->assertSame('csrf', $filter2->by); + $this->assertSame('throttle', $filter3->by); + $this->assertSame(['60', '1'], $filter3->having); + } + + public function testGetFiltersFormatIsConsistentAcrossInstances(): void + { + $filterWithoutArgs = new Filter(by: 'filter1'); + $filterWithArgs = new Filter(by: 'filter2', having: ['arg1']); + + $filters1 = $filterWithoutArgs->getFilters(); + $filters2 = $filterWithArgs->getFilters(); + + // Without args: simple array + $this->assertArrayNotHasKey('filter1', $filters1); + $this->assertContains('filter1', $filters1); + + // With args: associative array + $this->assertArrayHasKey('filter2', $filters2); + $this->assertIsArray($filters2['filter2']); + } + + public function testFilterWithNumericArguments(): void + { + $filter = new Filter(by: 'rate_limit', having: [100, 60]); + + $filters = $filter->getFilters(); + + $this->assertArrayHasKey('rate_limit', $filters); + $this->assertSame([100, 60], $filters['rate_limit']); + } + + public function testFilterWithMixedTypeArguments(): void + { + $filter = new Filter(by: 'custom', having: ['string', 123, true]); + + $filters = $filter->getFilters(); + + $this->assertArrayHasKey('custom', $filters); + $this->assertSame(['string', 123, true], $filters['custom']); + } + + public function testFilterWithAssociativeArrayArguments(): void + { + $filter = new Filter(by: 'configured', having: ['option1' => 'value1', 'option2' => 'value2']); + + $filters = $filter->getFilters(); + + $this->assertArrayHasKey('configured', $filters); + $this->assertSame(['option1' => 'value1', 'option2' => 'value2'], $filters['configured']); + } + + public function testBeforeDoesNotModifyRequest(): void + { + $filter = new Filter(by: 'auth', having: ['admin']); + $request = $this->createMockRequest('POST', '/admin/users'); + + $originalMethod = $request->getMethod(); + $originalPath = $request->getUri()->getPath(); + + $result = $filter->before($request); + + $this->assertNull($result); + $this->assertSame($originalMethod, $request->getMethod()); + $this->assertSame($originalPath, $request->getUri()->getPath()); + } + + public function testAfterDoesNotModifyResponse(): void + { + $filter = new Filter(by: 'toolbar'); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + $response->setBody('Test content'); + $response->setStatusCode(200); + + $result = $filter->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Test content', $response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + } + + private function createMockRequest(string $method, string $path, string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + $uri = new SiteURI($config, 'http://example.com' . $path . ($query !== '' ? '?' . $query : '')); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + $request->setMethod($method); + + return $request; + } +} diff --git a/tests/system/Router/Attributes/RestrictTest.php b/tests/system/Router/Attributes/RestrictTest.php new file mode 100644 index 000000000000..556884d51c77 --- /dev/null +++ b/tests/system/Router/Attributes/RestrictTest.php @@ -0,0 +1,464 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class RestrictTest extends CIUnitTestCase +{ + public function testConstructorWithNoRestrictions(): void + { + $restrict = new Restrict(); + + $this->assertNull($restrict->environment); + $this->assertNull($restrict->hostname); + $this->assertNull($restrict->subdomain); + } + + public function testConstructorWithEnvironmentOnly(): void + { + $restrict = new Restrict(environment: 'production'); + + $this->assertSame('production', $restrict->environment); + $this->assertNull($restrict->hostname); + $this->assertNull($restrict->subdomain); + } + + public function testConstructorWithHostnameOnly(): void + { + $restrict = new Restrict(hostname: 'example.com'); + + $this->assertNull($restrict->environment); + $this->assertSame('example.com', $restrict->hostname); + $this->assertNull($restrict->subdomain); + } + + public function testConstructorWithSubdomainOnly(): void + { + $restrict = new Restrict(subdomain: 'api'); + + $this->assertNull($restrict->environment); + $this->assertNull($restrict->hostname); + $this->assertSame('api', $restrict->subdomain); + } + + public function testConstructorWithMultipleRestrictions(): void + { + $restrict = new Restrict( + environment: ['production', 'staging'], + hostname: ['example.com', 'test.com'], + subdomain: ['api', 'admin'], + ); + + $this->assertSame(['production', 'staging'], $restrict->environment); + $this->assertSame(['example.com', 'test.com'], $restrict->hostname); + $this->assertSame(['api', 'admin'], $restrict->subdomain); + } + + public function testBeforeReturnsNullWhenNoRestrictionsSet(): void + { + $restrict = new Restrict(); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testAfterReturnsNull(): void + { + $restrict = new Restrict(environment: 'testing'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + $response = Services::response(); + + $result = $restrict->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testCheckEnvironmentAllowsCurrentEnvironment(): void + { + $restrict = new Restrict(environment: ENVIRONMENT); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckEnvironmentAllowsFromArray(): void + { + $restrict = new Restrict(environment: ['development', 'testing']); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckEnvironmentThrowsWhenNotAllowed(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is not allowed.'); + + $restrict = new Restrict(environment: 'production'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentThrowsWhenExplicitlyDenied(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is blocked.'); + + $currentEnv = ENVIRONMENT; + $restrict = new Restrict(environment: ['!' . $currentEnv]); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentDenialTakesPrecedence(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is blocked.'); + + $currentEnv = ENVIRONMENT; + // Include current env in allowed list but also deny it + $restrict = new Restrict(environment: [$currentEnv, '!' . $currentEnv]); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentAllowsWithOnlyDenials(): void + { + // Only deny production, allow everything else + $restrict = new Restrict(environment: ['!production', '!staging']); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckEnvironmentWithEmptyArray(): void + { + $restrict = new Restrict(environment: []); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameAllowsSingleHost(): void + { + $restrict = new Restrict(hostname: 'example.com'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameAllowsFromArray(): void + { + $restrict = new Restrict(hostname: ['example.com', 'test.com']); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameThrowsWhenNotAllowed(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Host is not allowed.'); + + $restrict = new Restrict(hostname: 'allowed.com'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckHostnameIsCaseInsensitive(): void + { + $restrict = new Restrict(hostname: 'EXAMPLE.COM'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameWithEmptyArray(): void + { + $restrict = new Restrict(hostname: []); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainAllowsSingleSubdomain(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainAllowsFromArray(): void + { + $restrict = new Restrict(subdomain: ['api', 'admin']); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainThrowsWhenNotAllowed(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: subdomain is blocked.'); + + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'admin.example.com'); + + $restrict->before($request); + } + + public function testCheckSubdomainThrowsWhenNoSubdomainExists(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckSubdomainIsCaseInsensitive(): void + { + $restrict = new Restrict(subdomain: 'API'); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainWithEmptyArray(): void + { + $restrict = new Restrict(subdomain: []); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainReturnsEmptyForLocalhost(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'localhost'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainReturnsEmptyForIPAddress(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', '192.168.1.1'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainReturnsEmptyForTwoPartDomain(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainHandlesSingleSubdomain(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainHandlesMultipleSubdomains(): void + { + $restrict = new Restrict(subdomain: 'admin.api'); + $request = $this->createMockRequest('GET', '/test', 'admin.api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainHandlesTwoPartTLD(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'api.example.co.uk'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainReturnsEmptyForDomainWithTwoPartTLD(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'example.co.uk'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainHandlesMultipleSubdomainsWithTwoPartTLD(): void + { + $restrict = new Restrict(subdomain: 'admin.api'); + $request = $this->createMockRequest('GET', '/test', 'admin.api.example.co.uk'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testMultipleRestrictionsAllMustPass(): void + { + $restrict = new Restrict( + environment: ENVIRONMENT, + hostname: ['api.example.com', 'example.com'], + subdomain: ['api'], + ); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testMultipleRestrictionsFailIfAnyFails(): void + { + $this->expectException(PageNotFoundException::class); + + $restrict = new Restrict( + environment: ENVIRONMENT, // Passes + hostname: 'api.example.com', // Passes + subdomain: 'admin', // Fails + ); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentFailsFirst(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is not allowed.'); + + $restrict = new Restrict( + environment: 'production', // Fails + hostname: 'example.com', // Would pass + ); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckHostnameFailsSecond(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Host is not allowed.'); + + $restrict = new Restrict( + environment: ENVIRONMENT, // Passes + hostname: 'allowed.com', // Fails + subdomain: 'api', // Would fail + ); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckSubdomainFailsLast(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: subdomain is blocked.'); + + $restrict = new Restrict( + environment: ENVIRONMENT, // Passes + hostname: 'api.example.com', // Passes + subdomain: 'admin', // Fails + ); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $restrict->before($request); + } + + private function createMockRequest(string $method, string $path, string $host = 'example.com', string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + // Use the host parameter to properly set the host in SiteURI + $uri = new SiteURI($config, $path . ($query !== '' ? '?' . $query : ''), $host, 'http'); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + $request->setMethod($method); + + return $request; + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 9399095c1d44..3438e832d55c 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -116,6 +116,8 @@ Helpers and Functions Others ====== +- **Controller Attributes:** Added support for PHP Attributes to define filters and other metadata on controller classes and methods. See :ref:`Controller Attributes ` for details. + *************** Message Changes *************** diff --git a/user_guide_src/source/incoming/controller_attributes.rst b/user_guide_src/source/incoming/controller_attributes.rst new file mode 100644 index 000000000000..10e93c37cddf --- /dev/null +++ b/user_guide_src/source/incoming/controller_attributes.rst @@ -0,0 +1,71 @@ +.. _incoming/controller_attributes: + +##################### +Controller Attributes +##################### + +PHP Attributes can be used to define filters and other metadata on controller classes and methods. This keeps the configuration close to the code it affects, and can make it easier to see at a glance what filters are applied to a given controller or method. This works across all routing methods, including auto-routing, which allows for a near feature-parity between the more robust route declarations and auto-routing. + +.. contents:: + :local: + :depth: 2 + +Getting Started +*************** + +Controller Attributes can be applied to either the entire class, or to a specific method. The following example shows how to apply the ``Filters`` attribute to a controller class: + +.. literalinclude:: controller_attributes/001.php + +In this example, the ``Auth`` filter will be applied to all methods in ``AdminController``. + +You can also apply the ``Filters`` attribute to a specific method within a controller. This allows you to apply filters only to certain methods, while leaving others unaffected. Here's an example: + +.. literalinclude:: controller_attributes/002.php + +Class-level and method-level attributes can work together to provide a flexible way to manage your routes at the controller level. + +Disabling Attributes +-------------------- + +If you know that you will not be using attributes in your application, you can disable the feature by setting the ``$useControllerAttributes`` property in your ``app/Config/Routing.php`` file to ``false``. + +Provided Attributes +******************* + +Filter +------- + +The ``Filters`` attribute allows you to specify one or more filters to be applied to a controller class or method. You can specify filters to run before or after the controller action, and you can also provide parameters to the filters. Here's an example of how to use the ``Filters`` attribute: + +.. literalinclude:: controller_attributes/003.php + +.. note:: + + When filters are applied both by an attribute and in the filter configuration file, they will both be applied, but that could lead to unexpected results. + +Restrict +-------- + +The ``Restrict`` attribute allows you to restrict access to the class or method based on the domain, the sub-domain, or +the environment the application is running in. Here's an exmaple of how to use the ``Restrict`` attribute: + +.. literalinclude:: controller_attributes/004.php + +Cache +----- + +The ``Cache`` attribute allows the output of the controller method to be cached for a specified amount of time. You can specify a duration in seconds, and optionally a cache key. Here's an example of how to use the ``Cache`` attribute: + +.. literalinclude:: controller_attributes/005.php + +Custom Attributes +***************** + +You can also create your own custom attributes to add metadata or behavior to your controllers and methods. Custom attributes must implement the ``CodeIgniter\Router\Attributes\RouteAttributeInterface`` interface. Here's an example of a custom attribute that adds a custom header to the response: + +.. literalinclude:: controller_attributes/006.php + +You can then apply this custom attribute to a controller class or method just like the built-in attributes: + +.. literalinclude:: controller_attributes/007.php diff --git a/user_guide_src/source/incoming/controller_attributes/001.php b/user_guide_src/source/incoming/controller_attributes/001.php new file mode 100644 index 000000000000..6b1bb4957d0d --- /dev/null +++ b/user_guide_src/source/incoming/controller_attributes/001.php @@ -0,0 +1,14 @@ +setHeader($this->name, $this->value); + + return $response; + } +} diff --git a/user_guide_src/source/incoming/controller_attributes/007.php b/user_guide_src/source/incoming/controller_attributes/007.php new file mode 100644 index 000000000000..07386c1c16d0 --- /dev/null +++ b/user_guide_src/source/incoming/controller_attributes/007.php @@ -0,0 +1,52 @@ +response->setJSON([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + } + + /** + * Add multiple custom headers using the IS_REPEATABLE attribute option. + * Each AddHeader attribute will be executed in order. + */ + #[AddHeader('X-API-Version', '2.0')] + #[AddHeader('X-Rate-Limit', '100')] + #[AddHeader('X-Content-Source', 'cache')] + public function statistics() + { + return $this->response->setJSON([ + 'users' => 1500, + 'posts' => 3200, + ]); + } + + /** + * Combine custom attributes with built-in attributes. + * The Cache attribute will cache the response, + * and AddHeader will add the custom header. + */ + #[AddHeader('X-Powered-By', 'My Custom API')] + #[Cache(for: 3600)] + public function dashboard() + { + return $this->response->setJSON([ + 'status' => 'operational', + 'uptime' => '99.9%', + ]); + } +} diff --git a/user_guide_src/source/incoming/index.rst b/user_guide_src/source/incoming/index.rst index d8faf0a869b5..ebcc316e10ee 100644 --- a/user_guide_src/source/incoming/index.rst +++ b/user_guide_src/source/incoming/index.rst @@ -10,6 +10,7 @@ Controllers handle incoming requests. routing controllers filters + controller_attributes auto_routing_improved message request diff --git a/utils/phpstan-baseline/assign.propertyType.neon b/utils/phpstan-baseline/assign.propertyType.neon index 813993f45e10..fa10aa1f575f 100644 --- a/utils/phpstan-baseline/assign.propertyType.neon +++ b/utils/phpstan-baseline/assign.propertyType.neon @@ -1,7 +1,12 @@ -# total 28 errors +# total 29 errors parameters: ignoreErrors: + - + message: '#^Property CodeIgniter\\CodeIgniter\:\:\$request \(CodeIgniter\\HTTP\\CLIRequest\|CodeIgniter\\HTTP\\IncomingRequest\|null\) does not accept CodeIgniter\\HTTP\\RequestInterface\.$#' + count: 1 + path: ../../system/CodeIgniter.php + - message: '#^Property CodeIgniter\\Controller\:\:\$request \(CodeIgniter\\HTTP\\CLIRequest\|CodeIgniter\\HTTP\\IncomingRequest\) does not accept CodeIgniter\\HTTP\\RequestInterface\.$#' count: 1 diff --git a/utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon b/utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon new file mode 100644 index 000000000000..a6c8edb13d11 --- /dev/null +++ b/utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon @@ -0,0 +1,4 @@ +# total 0 errors + +parameters: + ignoreErrors: [] diff --git a/utils/phpstan-baseline/isset.property.neon b/utils/phpstan-baseline/isset.property.neon new file mode 100644 index 000000000000..8af257e29e83 --- /dev/null +++ b/utils/phpstan-baseline/isset.property.neon @@ -0,0 +1,43 @@ +# total 8 errors + +parameters: + ignoreErrors: + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$description \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/CLI/BaseCommand.php + + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$usage \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/CLI/BaseCommand.php + + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$group \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/CLI/Commands.php + + - + message: '#^Property CodeIgniter\\Database\\BaseConnection\\:\:\$strictOn \(bool\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/Database/MySQLi/Connection.php + + - + message: '#^Property CodeIgniter\\Database\\BaseConnection\\:\:\$password \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/Database/SQLite3/Connection.php + + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$group \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../tests/system/Commands/BaseCommandTest.php + + - + message: '#^Property CodeIgniter\\Database\\BaseConnection\\:\:\$charset \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../tests/system/Database/BaseConnectionTest.php + + - + message: '#^Property CodeIgniter\\I18n\\TimeDifference\:\:\$days \(float\|int\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../tests/system/I18n/TimeDifferenceTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 4a3fe00ddf57..7570aded7a3d 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2795 errors +# total 2815 errors includes: - argument.type.neon @@ -10,6 +10,7 @@ includes: - codeigniter.superglobalAccessAssign.neon - deadCode.unreachable.neon - empty.notAllowed.neon + - isset.property.neon - method.alreadyNarrowedType.neon - method.childParameterType.neon - method.childReturnType.neon @@ -24,6 +25,7 @@ includes: - property.nonObject.neon - property.notFound.neon - property.phpDocType.neon + - return.type.neon - staticMethod.notFound.neon - ternary.shortNotAllowed.neon - varTag.type.neon diff --git a/utils/phpstan-baseline/method.alreadyNarrowedType.neon b/utils/phpstan-baseline/method.alreadyNarrowedType.neon index f3c373da1f4a..1e4c261c6d5d 100644 --- a/utils/phpstan-baseline/method.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/method.alreadyNarrowedType.neon @@ -1,4 +1,4 @@ -# total 22 errors +# total 24 errors parameters: ignoreErrors: @@ -22,6 +22,11 @@ parameters: count: 1 path: ../../tests/system/CodeIgniterTest.php + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/Commands/BaseCommandTest.php + - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsBool\(\) with bool will always evaluate to true\.$#' count: 1 @@ -32,6 +37,11 @@ parameters: count: 2 path: ../../tests/system/Config/FactoriesTest.php + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/Database/BaseConnectionTest.php + - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' count: 2 diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 9bb3a84ef6c0..9df65635283e 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1386 errors +# total 1391 errors parameters: ignoreErrors: @@ -4212,6 +4212,31 @@ parameters: count: 1 path: ../../system/Publisher/Publisher.php + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Filter\:\:__construct\(\) has parameter \$having with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Filter.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Filter\:\:getFilters\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Filter.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Restrict\:\:__construct\(\) has parameter \$environment with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Restrict.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Restrict\:\:__construct\(\) has parameter \$hostname with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Restrict.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Restrict\:\:__construct\(\) has parameter \$subdomain with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Restrict.php + - message: '#^Method CodeIgniter\\Router\\AutoRouter\:\:getRoute\(\) return type has no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/nullCoalesce.property.neon b/utils/phpstan-baseline/nullCoalesce.property.neon index a5889293f5fa..e473da4a4a52 100644 --- a/utils/phpstan-baseline/nullCoalesce.property.neon +++ b/utils/phpstan-baseline/nullCoalesce.property.neon @@ -1,4 +1,4 @@ -# total 8 errors +# total 11 errors parameters: ignoreErrors: @@ -27,6 +27,21 @@ parameters: count: 1 path: ../../system/Throttle/Throttler.php + - + message: '#^Property CodeIgniter\\HomeTest\:\:\$session \(array\\) on left side of \?\? is not nullable\.$#' + count: 1 + path: ../../tests/system/HomeTest.php + + - + message: '#^Property CodeIgniter\\Test\\FeatureTestAutoRoutingImprovedTest\:\:\$session \(array\\) on left side of \?\? is not nullable\.$#' + count: 1 + path: ../../tests/system/Test/FeatureTestAutoRoutingImprovedTest.php + + - + message: '#^Property CodeIgniter\\Test\\FeatureTestTraitTest\:\:\$session \(array\\) on left side of \?\? is not nullable\.$#' + count: 1 + path: ../../tests/system/Test/FeatureTestTraitTest.php + - message: '#^Property CodeIgniter\\Test\\FilterTestTraitTest\:\:\$request \(CodeIgniter\\HTTP\\RequestInterface\) on left side of \?\?\= is not nullable\.$#' count: 1 diff --git a/utils/phpstan-baseline/return.type.neon b/utils/phpstan-baseline/return.type.neon new file mode 100644 index 000000000000..40b214c3fce9 --- /dev/null +++ b/utils/phpstan-baseline/return.type.neon @@ -0,0 +1,8 @@ +# total 1 error + +parameters: + ignoreErrors: + - + message: '#^Method CodeIgniter\\Router\\Router\:\:getRouteAttributes\(\) should return array\{class\: list\, method\: list\\} but returns array\{class\: list\, method\: list\\}\.$#' + count: 1 + path: ../../system/Router/Router.php