From 5a2dd92355051e063848017e76b7fdd7ae074bf8 Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Thu, 1 Jan 2026 13:08:23 +0600 Subject: [PATCH 01/38] feat: static router to render wordpress page/post url for custom usecases --- src/Http/Router/RouteRegister.php | 86 +++++++---- src/Http/Router/StaticRouter.php | 237 ++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 src/Http/Router/StaticRouter.php diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php index bc51dfa..55daf65 100644 --- a/src/Http/Router/RouteRegister.php +++ b/src/Http/Router/RouteRegister.php @@ -6,12 +6,14 @@ use BitApps\WPKit\Http\RequestType; use BitApps\WPKit\Http\Response; +use Closure; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; use WP_REST_Request; use WP_REST_Response; + final class RouteRegister { private $_name; @@ -35,21 +37,21 @@ final class RouteRegister private $_middleware = []; /** - * Instance of rest request + * Instance of rest request. * * @var WP_REST_Response */ private $_restResponse; /** - * Instance of rest request + * Instance of rest request. * * @var WP_REST_Request */ private $_restRequest; /** - * Instance of Request + * Instance of Request. * * @var Request */ @@ -155,7 +157,8 @@ public function hasRegex() return false; } - return !(preg_match_all('/\{\w+\??\}\??/', $this->_path, $this->_regexMatched) === false + return !( + preg_match_all('/\{\w+\??\}\??/', $this->_path, $this->_regexMatched) === false || empty($this->_regexMatched[0]) ); } @@ -181,8 +184,8 @@ public function handleMiddleware() $router = $this->getRouter(); foreach ($middlewares as $middleware) { $middlewareData = explode(':', (string) $middleware); - $middleware = $middlewareData[0]; - $params = []; + $middleware = $middlewareData[0]; + $params = []; if (isset($middlewareData[1])) { $params = explode(',', (string) $middlewareData[1]); } @@ -253,7 +256,7 @@ public function getParamValue(ReflectionParameter $param) return $value; } - if (Request::class === $type || is_subclass_of($type, Request::class)) { + if ($type === Request::class || is_subclass_of($type, Request::class)) { $this->setRequest($type); $value = $this->getRequest(); } elseif ($isRouteParam && $value === $isRouteParam && method_exists($type, '__construct')) { @@ -318,14 +321,9 @@ public function handleRequest() if (\func_num_args() && ($apiRequest = \func_get_args()[0]) instanceof WP_REST_Request) { $this->setRestRequest($apiRequest); } - $this->handleMiddleware(); $this->handleAction($this); - if (ob_get_level()) { - ob_clean(); - } - return $this->sendResponse(); } @@ -383,7 +381,7 @@ private function authorize() private function validate() { if (method_exists($this->_request, 'rules')) { - $messages = []; + $messages = []; $attributes = []; if (method_exists($this->_request, 'messages')) { @@ -419,9 +417,9 @@ private function register($method, $path, $action) private function makeRegex() { - $path = str_replace('/', '\\/', $this->_path); + $path = str_replace('/', '\/', $this->_path); foreach ($this->_regexMatched[0] as $param) { - $name = trim($param, '{}?'); + $name = trim($param, '{}?'); $required = true; if (strpos($param, '?')) { $required = false; @@ -429,7 +427,7 @@ private function makeRegex() $this->setRouteParam($name, ['required' => $required]); $regexToSet = "(?P<{$name}>[^\\/]+)" . ($required ? '' : '?'); - $path = str_replace($param, $regexToSet, $path); + $path = str_replace($param, $regexToSet, $path); } return $path; @@ -443,12 +441,40 @@ private function setRouteParam($name, $attribute) private function handleAction() { $action = $this->getAction(); - if (method_exists($action[0], $action[1])) { + if (\is_array($action) && method_exists($action[0], $action[1])) { $response = $this->invokeAsReflection($action[0], $action[1]); - $this->setResponse($response); + } elseif (\is_callable($action)) { + $response = $this->invokeAsReflectionFunction($action); } else { - $this->setResponse(Response::message('Route action doesn\'t exists')); + $response = Response::message('Route action doesn\'t exists'); + } + $this->setResponse($response); + } + + private function invokeAsReflectionFunction(Closure|string $method, $params = []) + { + $reflectionFunction = new ReflectionFunction($method); + $reflectionParams = $reflectionFunction->getParameters(); + + $params = $this->processParameters($reflectionParams, $params); + + if (RequestType::is(RequestType::API) && isset($this->_restResponse)) { + // maybe failed at middleware,authorization or validation + + return Response::instance(); + } + + return $reflectionFunction->invoke(...$params); + } + + private function processParameters($reflectionParams, $params = []) + { + $requestParams = []; + foreach ($reflectionParams as $param) { + $requestParams[] = $this->getParamValue($param); } + + return array_merge($requestParams, $params); } private function invokeAsReflection($class, $method, $params = []) @@ -459,23 +485,19 @@ private function invokeAsReflection($class, $method, $params = []) /** * If the ReflectionMethod is a method of a Middleware then we will set the first parameter. * First parameter will be Request object - * Rest of params will be from Middleware ex: 'role:admin' + * Rest of params will be from Middleware ex: 'role:admin'. * * If params count is 0 then the method is handle of Middleware and called from handleMiddleware */ $reflectionParams = \count($params) === 0 ? $reflectionParams : [$reflectionParams[0]]; - $requestParams = []; - foreach ($reflectionParams as $param) { - $requestParams[] = $this->getParamValue($param); - } + $params = $this->processParameters($reflectionParams, $params); if (RequestType::is(RequestType::API) && isset($this->_restResponse)) { // maybe failed at middleware,authorization or validation return Response::instance(); } - $params = array_merge($requestParams, $params); return $reflectionMethod->invoke($reflectionMethod->isStatic() ? null : new $class(), ...$params); } @@ -503,7 +525,8 @@ private function setResponse($response) } $responseData['data'] = $response->getData(); - $additional = ob_get_clean(); + $additional = wp_ob_end_flush_all(); + if (!empty($additional)) { $responseData['additional'] = $additional; } @@ -517,11 +540,15 @@ private function setResponse($response) private function sendResponse() { - if (RequestType::API === $this->getRouterType()) { + if ($this->getRouterType() === RequestType::API) { return $this->sendApiResponse(); } + if ($this->getRouterType() === RequestType::AJAX) { + return $this->sendAjaxResponse(); + } - $this->sendAjaxResponse(); + // return only data for web routes. Other types handle response themselves. data is html content. + return $this->_response['data']['data']; } private function sendApiResponse() @@ -531,7 +558,7 @@ private function sendApiResponse() $restResponse->set_status($this->_response['http_status']); $restResponse->set_headers($this->_response['headers']); - $this->_restResponse = $restResponse; // will USE this to return before middleware or action excutes + $this->_restResponse = $restResponse; // will be used to return before middleware or action excutes return $restResponse; } @@ -547,3 +574,4 @@ private function sendAjaxResponse() wp_send_json($this->_response['data'], $this->_response['http_status']); } } + diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php new file mode 100644 index 0000000..385e844 --- /dev/null +++ b/src/Http/Router/StaticRouter.php @@ -0,0 +1,237 @@ +router = Router::instance('static', $this->pageName); + $this->registerHooks(); + } + + public function flushOnActivate() + { + $this->registerRewriteRules(); + flush_rewrite_rules(); + } + + public function flushOnDeactivate() + { + flush_rewrite_rules(); + } + + public function registerRewriteRules() + { + $this->processRoutes(); + + if (empty($this->rewriteRules)) { + return; + } + + + foreach ($this->rewriteRules as $regex => $query) { + add_rewrite_rule($regex, $query, 'top'); + } + } + + public function addQueryVars($vars) + { + if (empty($this->rewriteRules)) { + return $vars; + } + $this->maybeFlashRewriteRules(); + $uniqueQueryVars = array_unique($this->queryVars); + + return array_merge($vars, $uniqueQueryVars); + } + + public function handleRequest() + { + $requestPath = sanitize_url($_SERVER['REQUEST_URI']) ?? ''; + $pageName = trim($this->pageName, '/'); + foreach ($this->router->getRoutes() as $route) { + /** + * RouteRegister instance to check against. + * + * @var RouteRegister $route + */ + $path = '/' . $pageName . '/' . $route->getPath(); + $prefix = $route->getRoutePrefix(); + + if ($prefix) { + $path = $prefix . '/' . $path; + } + if ($this->isRouteMatched($path, $requestPath)) { + $this->setRouteParameters($route, $requestPath); + $this->content = $route->handleRequest(); + + return; + } + } + } + + public function renderContent() + { + + return $this->content ?? ''; + } + + public function loadRoutesFromFile($filePath) + { + $this->router->registerFile($filePath); + } + + public function getRouter() + { + return $this->router; + } + + public static function isRewriteExists(?string $path = '', ?array $rewriteRules = null): bool + { + if (empty($path) && empty($rewriteRules)) { + return false; + } + + $rules = get_option('rewrite_rules'); + $ignorePatterns = ['(.?.+?)(?:/([0-9]+))?/?$', '([^/]+)(?:/([0-9]+))?/?$']; + $rulesToCheck = $path ? ['^' . trim($path, '/')] : array_keys($rewriteRules); + + if ($rules) { + $patterns = array_keys($rules); + foreach ($patterns as $pattern) { + if (!\in_array($pattern, $ignorePatterns, true) && \in_array($pattern, $rulesToCheck, true)) { + return true; + } + } + } + + return false; + } + + public function maybeFlashRewriteRules() + { + if (empty($this->rewriteRules) || $this->isRewriteExists(rewriteRules: $this->rewriteRules)) { + // error_log('Rewrite rules already exist, skipping flush.'); + return; + } + + flush_rewrite_rules(); + } + + private function registerHooks() + { + add_action($this->activationHook, [$this, 'flushOnDeactivate']); + add_action($this->deactivationHook, [$this, 'flushOnActivate']); + add_action('init', [$this, 'registerRewriteRules']); + add_action('query_vars', [$this, 'addQueryVars']); + add_filter('the_content', [$this, 'renderContent']); + add_action('template_redirect', [$this, 'handleRequest']); + } + + private function processRoutes() + { + $routes = $this->router->getRoutes(); + + foreach ($routes as $route) { + /** + * RouteRegister instance to process. + * + * @var RouteRegister $route + */ + $path = $route->getPath(); + $prefix = $route->getRoutePrefix(); + + if ($prefix) { + $path = $prefix . '/' . $path; + } + + $this->makeRewriteRuleForPath($path); + } + } + + private function isRouteMatched($routePath, $requestPath) + { + // Replace route parameters with regex pattern for matching + $pattern = preg_replace('/\{(\w+)\}/', '([^/]+)', $routePath); + $pattern = '^' . $pattern . '/?$'; + + return preg_match('~' . $pattern . '~', $requestPath); + } + + private function setRouteParameters(RouteRegister $route, $requestPath) + { + $path = $route->getPath(); + $prefix = $route->getRoutePrefix(); + + if ($prefix) { + $path = $prefix . '/' . $path; + } + + $cleanPath = trim($path, '/'); + + preg_match_all('/\{(\w+)\}/', $cleanPath, $matches); + $routeParams = $matches[1]; + + if (empty($routeParams)) { + return; + } + + $regex = '~^' . preg_replace('/\{(\w+)\}/', '([^/]+)', $cleanPath) . '~'; + + if (preg_match($regex, $requestPath, $matchedValues)) { + // Skip the full match at index 0 + array_shift($matchedValues); + + foreach ($routeParams as $i => $param) { + if (isset($matchedValues[$i])) { + $route->setRouteParamValue($param, $matchedValues[$i]); + } + } + } + } + + private function makeRewriteRuleForPath(string $path) + { + preg_match_all('/\{\w+\??\}\??/', $path, $regexMatched); + $pagename = trim($this->pageName, '/'); + $path = $pagename . '/' . trim($path, '/') . '/'; + $this->rewriteRules = []; + $this->rewriteRules["^{$pagename}/?$"] = "index.php?pagename={$pagename}"; + $matchCount = 1; + $previousPath = "^{$pagename}/?$"; + while ($param = array_shift($regexMatched[0])) { + $param = trim($param, '{}?'); + $pathChunk = substr($path, 0, strpos($path, "{{$param}}")); + $pathChunkWithoutParam = '^' . $pathChunk . '?$'; + $pathChunkWitParam = '^' . $pathChunk . '([^/]+)/?$'; + + $path = str_replace("{{$param}}", '([^/]+)', $path); + if (!isset($this->rewriteRules[$pathChunkWithoutParam]) && strpos($pathChunkWithoutParam, '([^/]+)')) { + $previousPath = trim(substr($pathChunkWithoutParam, 0, strpos($pathChunkWithoutParam, '([^/]+)') + \strlen('([^/]+)') + 1), '/') . '/?$'; + } + $this->rewriteRules[$pathChunkWithoutParam] = $this->rewriteRules[$previousPath]; + $this->rewriteRules[$pathChunkWitParam] = $this->rewriteRules[$pathChunkWithoutParam] . "&{$param}=\$matches[{$matchCount}]"; + ++$matchCount; + $this->queryVars[] = $param; + } + } +} From a9c755c0c282affd9d90a4caa2bbf8fc9a9ddff9 Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Thu, 1 Jan 2026 13:33:15 +0600 Subject: [PATCH 02/38] fix: route path not matching for root --- src/Http/Router/StaticRouter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 385e844..a6c4508 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -74,7 +74,7 @@ public function handleRequest() * * @var RouteRegister $route */ - $path = '/' . $pageName . '/' . $route->getPath(); + $path = '/' . $pageName . '/' . trim($route->getPath(), '/'); $prefix = $route->getRoutePrefix(); if ($prefix) { @@ -91,7 +91,6 @@ public function handleRequest() public function renderContent() { - return $this->content ?? ''; } From 3ef566517b1951b3b0fe5cfc071c95a6782f033f Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Thu, 1 Jan 2026 17:04:34 +0600 Subject: [PATCH 03/38] fix: returning '' on the_content filter --- src/Http/Router/StaticRouter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index a6c4508..000ebc3 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -89,9 +89,9 @@ public function handleRequest() } } - public function renderContent() + public function renderContent(string $content): string { - return $this->content ?? ''; + return $content . $this->content ?? $content; } public function loadRoutesFromFile($filePath) From 4fa5b177a9cf5ec2733239440b2d078b463f526f Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Thu, 1 Jan 2026 17:44:10 +0600 Subject: [PATCH 04/38] fix: accessing property before initialized --- src/Http/Router/StaticRouter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 000ebc3..0d4e039 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -91,7 +91,7 @@ public function handleRequest() public function renderContent(string $content): string { - return $content . $this->content ?? $content; + return $content . ($this->content ?? ''); } public function loadRoutesFromFile($filePath) @@ -99,7 +99,7 @@ public function loadRoutesFromFile($filePath) $this->router->registerFile($filePath); } - public function getRouter() + public function getRouter(): Router { return $this->router; } From 39af6d515d65580b2cb13e6317b1abdbeb8ceaf0 Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Thu, 1 Jan 2026 17:47:15 +0600 Subject: [PATCH 05/38] chore: refactor. added precaution --- src/Http/Router/StaticRouter.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 0d4e039..802d0e0 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -82,6 +82,11 @@ public function handleRequest() } if ($this->isRouteMatched($path, $requestPath)) { $this->setRouteParameters($route, $requestPath); + /** + * this filter needs to be added here to avoid affecting other routes + */ + add_filter('the_content', [$this, 'renderContent']); + $this->content = $route->handleRequest(); return; @@ -142,7 +147,6 @@ private function registerHooks() add_action($this->deactivationHook, [$this, 'flushOnActivate']); add_action('init', [$this, 'registerRewriteRules']); add_action('query_vars', [$this, 'addQueryVars']); - add_filter('the_content', [$this, 'renderContent']); add_action('template_redirect', [$this, 'handleRequest']); } From c91bd89a6ca0c6eae1d6d9ce3dd7db978d2983ea Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Sat, 3 Jan 2026 17:28:45 +0600 Subject: [PATCH 06/38] chore: fix type --- src/Http/Response.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Response.php b/src/Http/Response.php index 2ad2ba4..b8de559 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -142,7 +142,7 @@ public static function getCode() /** * Sets http status code for response. * - * @param string $code http status code to return on response + * @param int $code http status code to return on response * * @return self */ From 377dd238618b481aa5cbb3573b3129039bbba0c4 Mon Sep 17 00:00:00 2001 From: abdul-kaioum Date: Tue, 6 Jan 2026 10:02:39 +0600 Subject: [PATCH 07/38] fix: namespace --- src/Http/Router/StaticRouter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 802d0e0..9225447 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -1,6 +1,6 @@ Date: Tue, 27 Jan 2026 10:33:15 +0600 Subject: [PATCH 08/38] Fix: static route regex --- src/Http/Router/StaticRouter.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 9225447..7c0095c 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -74,19 +74,17 @@ public function handleRequest() * * @var RouteRegister $route */ - $path = '/' . $pageName . '/' . trim($route->getPath(), '/'); $prefix = $route->getRoutePrefix(); - + $path = $pageName . '/' . $prefix; + $path = '/' . trim($path, '/') . '/' . trim($route->getPath(), '/'); if ($prefix) { $path = $prefix . '/' . $path; } if ($this->isRouteMatched($path, $requestPath)) { $this->setRouteParameters($route, $requestPath); - /** - * this filter needs to be added here to avoid affecting other routes - */ + // this filter needs to be added here to avoid affecting other routes add_filter('the_content', [$this, 'renderContent']); - + $this->content = $route->handleRequest(); return; @@ -198,9 +196,8 @@ private function setRouteParameters(RouteRegister $route, $requestPath) return; } - $regex = '~^' . preg_replace('/\{(\w+)\}/', '([^/]+)', $cleanPath) . '~'; - - if (preg_match($regex, $requestPath, $matchedValues)) { + $regex = '~^' . trim($this->pageName, '/') . '/' . preg_replace('/\{(\w+)\}/', '([^/]+)', $cleanPath) . '~'; + if (preg_match($regex, trim($requestPath, '/'), $matchedValues)) { // Skip the full match at index 0 array_shift($matchedValues); From f27a354f423984e9a61dd960acbcc4cc939298e8 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 11 Jun 2026 16:19:38 +0600 Subject: [PATCH 09/38] fix: chained middleware() dropped args, use array_merge Assisted-By: AI --- src/Http/Router/RouteBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Router/RouteBase.php b/src/Http/Router/RouteBase.php index 74690e8..12ed4f3 100644 --- a/src/Http/Router/RouteBase.php +++ b/src/Http/Router/RouteBase.php @@ -137,7 +137,7 @@ public function ignoreToken() */ public function middleware() { - $this->_middleware = (array) $this->_middleware + \func_get_args(); + $this->_middleware = array_merge($this->_middleware, \func_get_args()); return $this; } From 972e1332279971346a25deb371ee05f2d1aa7bb0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 29 Jul 2026 13:17:24 +0600 Subject: [PATCH 10/38] refactor: BC-safe restructure of HTTP routing + security hardening Internal restructure with the public API frozen (consumed by downstream plugins). Response moves to instance state behind a static facade; Router gains a type-keyed registry; route blocking uses an internal exception instead of a polled flag; response emission becomes per-transport strategies; one RoutePattern compiler serves REST/AJAX/static matching; IpTool splits into Http\\Detection\\{ClientIpResolver,UserAgent}. New collaborators: RoutePattern, MiddlewareRegistry, ResponseEnvelope, RewriteRuleSet, RouteBlockedException, MiddlewareConfigurationException, Emitter\\{ResponseEmitter,Api,Ajax,Static}ResponseEmitter. Security: HttpClient safe-by-default (wp_safe_remote_request, allowUnsafeUrls opt-in); trusted-proxy X-Forwarded-For resolution; fail-closed middleware; response header-injection validation. Adds a PHPUnit contract suite + PHPDBG coverage gate (100% on selected HTTP methods). PHP 7.4 compatible. Assisted-By: AI --- README.md | 65 +++ composer.json | 15 +- phpunit.xml | 13 + src/Helpers/DateTimeHelper.php | 2 +- src/Http/Client/HttpClient.php | 17 +- src/Http/Detection/ClientIpResolver.php | 111 ++++ src/Http/Detection/UserAgent.php | 274 +++++++++ src/Http/IpTool.php | 311 +--------- src/Http/RequestType.php | 2 + src/Http/Response.php | 123 ++-- .../Router/Emitter/AjaxResponseEmitter.php | 17 + .../Router/Emitter/ApiResponseEmitter.php | 18 + src/Http/Router/Emitter/ResponseEmitter.php | 15 + .../Router/Emitter/StaticResponseEmitter.php | 12 + .../MiddlewareConfigurationException.php | 9 + src/Http/Router/MiddlewareRegistry.php | 41 ++ src/Http/Router/ResponseEnvelope.php | 62 ++ src/Http/Router/RewriteRuleSet.php | 58 ++ src/Http/Router/RouteBlockedException.php | 24 + src/Http/Router/RoutePattern.php | 58 ++ src/Http/Router/RouteRegister.php | 366 ++++++------ src/Http/Router/Router.php | 61 +- src/Http/Router/StaticRouter.php | 160 ++--- src/Installer.php | 4 +- src/Migration/Migration.php | 1 + .../Fixtures/Migrations/ContractMigration.php | 20 + .../wordpress/wp-admin/includes/upgrade.php | 3 + tests/Helpers/ArrayTest.php | 152 +++++ tests/Helpers/DateTimeTest.php | 82 +++ tests/Helpers/JsonAndSlugTest.php | 53 ++ tests/Http/ClientIpResolverTest.php | 104 ++++ tests/Http/HttpClientTest.php | 160 +++++ tests/Http/RequestTest.php | 107 ++++ tests/Http/ResponseTest.php | 128 ++++ tests/Http/UserAgentTest.php | 28 + tests/Lifecycle/MigrationAndInstallerTest.php | 181 ++++++ tests/README.md | 24 + tests/Router/DispatchTest.php | 290 ++++++++++ tests/Router/MiddlewareTest.php | 220 +++++++ tests/Router/ResponseEmissionTest.php | 56 ++ tests/Router/ResponseEnvelopeTest.php | 48 ++ tests/Router/RewriteRuleSetTest.php | 50 ++ tests/Router/RouteDefinitionTest.php | 139 +++++ tests/Router/RoutePatternTest.php | 61 ++ tests/Router/RouterIdentityTest.php | 58 ++ tests/Router/StaticRoutingTest.php | 239 ++++++++ tests/Router/TransportRegistrationTest.php | 114 ++++ tests/TestCase.php | 15 + tests/WordPress/FacadeTest.php | 124 ++++ tests/bootstrap.php | 545 ++++++++++++++++++ tests/coverage.php | 116 ++++ 51 files changed, 4293 insertions(+), 663 deletions(-) create mode 100644 phpunit.xml create mode 100644 src/Http/Detection/ClientIpResolver.php create mode 100644 src/Http/Detection/UserAgent.php create mode 100644 src/Http/Router/Emitter/AjaxResponseEmitter.php create mode 100644 src/Http/Router/Emitter/ApiResponseEmitter.php create mode 100644 src/Http/Router/Emitter/ResponseEmitter.php create mode 100644 src/Http/Router/Emitter/StaticResponseEmitter.php create mode 100644 src/Http/Router/MiddlewareConfigurationException.php create mode 100644 src/Http/Router/MiddlewareRegistry.php create mode 100644 src/Http/Router/ResponseEnvelope.php create mode 100644 src/Http/Router/RewriteRuleSet.php create mode 100644 src/Http/Router/RouteBlockedException.php create mode 100644 src/Http/Router/RoutePattern.php create mode 100644 tests/Fixtures/Migrations/ContractMigration.php create mode 100644 tests/Fixtures/wordpress/wp-admin/includes/upgrade.php create mode 100644 tests/Helpers/ArrayTest.php create mode 100644 tests/Helpers/DateTimeTest.php create mode 100644 tests/Helpers/JsonAndSlugTest.php create mode 100644 tests/Http/ClientIpResolverTest.php create mode 100644 tests/Http/HttpClientTest.php create mode 100644 tests/Http/RequestTest.php create mode 100644 tests/Http/ResponseTest.php create mode 100644 tests/Http/UserAgentTest.php create mode 100644 tests/Lifecycle/MigrationAndInstallerTest.php create mode 100644 tests/README.md create mode 100644 tests/Router/DispatchTest.php create mode 100644 tests/Router/MiddlewareTest.php create mode 100644 tests/Router/ResponseEmissionTest.php create mode 100644 tests/Router/ResponseEnvelopeTest.php create mode 100644 tests/Router/RewriteRuleSetTest.php create mode 100644 tests/Router/RouteDefinitionTest.php create mode 100644 tests/Router/RoutePatternTest.php create mode 100644 tests/Router/RouterIdentityTest.php create mode 100644 tests/Router/StaticRoutingTest.php create mode 100644 tests/Router/TransportRegistrationTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/WordPress/FacadeTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/coverage.php diff --git a/README.md b/README.md index bb94cf0..990e4ef 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,68 @@ ``` composer require bitapps/wp-kit:dev-main ``` + +## Security defaults + +- Route middleware fails closed. Every alias passed to `middleware()` must be + registered with `Router::setMiddlewares()`, and its class must define + `handle()`. +- WPKit does not infer route authorization. Protect non-public REST/static + routes with middleware or a request `authorize()` method, and add capability + plus nonce checks to state-changing AJAX routes. +- `Request::ip()` uses `REMOTE_ADDR` unless that address is a configured trusted + proxy. Configure exact proxy addresses or CIDR ranges before accepting + `X-Forwarded-For`: + + ```php + Request::setTrustedProxies(['10.0.0.0/8', '2001:db8::/32']); + ``` + +- `HttpClient` uses `wp_safe_remote_request()` by default. Internal or otherwise + unsafe URLs require an explicit opt-in: + + ```php + $client->allowUnsafeUrls(); + ``` + +## Upgrade notes + +- `Response::headers()` now requires an array and validates every entry through + `Response::header()`; invalid header names, values containing CR/LF/NUL, and + non-scalar values throw `InvalidArgumentException` instead of being stored + silently. +- `Router::getRegisteredMiddleware()` throws + `MiddlewareConfigurationException` for unregistered aliases, missing classes, + or classes without `handle()` — it previously returned `null`. Route dispatch + catches this internally and responds with `MIDDLEWARE_CONFIGURATION`; only + direct callers need to migrate. +- `Router::instance($type)` now returns the router of the requested type (or + creates one) instead of silently returning whatever router was constructed + last. No-argument calls keep returning the current router. Creating a router + (directly or via `instance($type)` miss) still makes it the current router, + so declare routes before constructing transports. +- `Response::getCode()` returns `null` (previously `''`) when neither a code + nor a status has been set. +- `RouteRegister::handleMiddleware()` called directly now returns `true`/`false` + (previously `null`) and records the denial response without emitting it; + full dispatch through `handleRequest()` is unchanged. +- Direct calls to `RouteRegister::getRequest()`/`getParamValue()` on a denying + or invalid request record the failure response and return the built request / + `null` respectively — they never throw. +- Route paths are compiled by one grammar for REST, AJAX, and static routes: + literal segments are regex-quoted, `{param?}` now also matches with no + trailing separator (`entries/{slug?}` matches `entries`), and duplicate or + invalid parameter names throw `InvalidArgumentException` at registration. + Trade-off: an optional param no longer matches the empty-value-with-trailing- + slash form (`entries/`) on AJAX routes — use `entries` (no slash) instead. + +## Tests + +```bash +composer test +composer coverage +``` + +PHPUnit tests are grouped by public feature under `tests/` to protect +compatibility while internals are refactored. The PHPDBG coverage command +enforces 100% executable-line coverage for selected HTTP hardening methods. diff --git a/composer.json b/composer.json index b678c0c..06d9b4f 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "composer.lock", ".vscode", ".php-cs-fixer.cache", - "phpcs.xml" + "phpcs.xml", + "tests" ] }, "require": { @@ -26,16 +27,24 @@ "friendsofphp/php-cs-fixer": "^3.10", "sirbrillig/phpcs-variable-analysis": "*", "dealerdirect/phpcodesniffer-composer-installer": "^0.7", - "phpcompatibility/phpcompatibility-wp": "*" + "phpcompatibility/phpcompatibility-wp": "*", + "phpunit/phpunit": "^13.0" }, "autoload": { "psr-4": { "BitApps\\WPKit\\": "./src" } }, + "autoload-dev": { + "psr-4": { + "BitApps\\WPKit\\Tests\\": "./tests" + } + }, "scripts": { "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php", - "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 7.4-" + "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 7.4-", + "test": "phpunit --configuration phpunit.xml", + "coverage": "phpdbg -qrr tests/coverage.php" }, "extra": { "branch-alias": { diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..15fb8a6 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,13 @@ + + + + + tests + + + + + src + + + diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php index 2f1ab58..d210634 100644 --- a/src/Helpers/DateTimeHelper.php +++ b/src/Helpers/DateTimeHelper.php @@ -333,7 +333,7 @@ public static function wp_timezone_string() $absHour = abs($hours); $absMins = abs($minutes * 60); - return sprintf('%s%02d:%02d', $sign, $absHour, $absMins); + return \sprintf('%s%02d:%02d', $sign, $absHour, $absMins); } public static function wp_timezone() diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 315532a..ac3fa88 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -35,6 +35,8 @@ final class HttpClient private $_options = []; + private $_allowUnsafeUrls = false; + /** * Undocumented function. * @@ -122,6 +124,13 @@ public function setOptions(array $options) return $this; } + public function allowUnsafeUrls($allow = true) + { + $this->_allowUnsafeUrls = (bool) $allow; + + return $this; + } + public function setBoundary($boundary) { $this->_boundary = '-------' . (string) $boundary; @@ -227,7 +236,9 @@ public function request($url, $type, $data, $headers = null, $options = null) ]; $options = wp_parse_args($options, $defaultOptions); - $requestResponse = wp_remote_request($url, $options); + $requestResponse = $this->_allowUnsafeUrls + ? wp_remote_request($url, $options) + : wp_safe_remote_request($url, $options); $this->_requestResponse = $requestResponse; @@ -283,6 +294,10 @@ public function setDefault(array $config) if (isset($config['multipart'])) { $this->setMultipart($config['multipart']); } + + if (isset($config['allow_unsafe_urls'])) { + $this->allowUnsafeUrls($config['allow_unsafe_urls']); + } } public function setJson($data) diff --git a/src/Http/Detection/ClientIpResolver.php b/src/Http/Detection/ClientIpResolver.php new file mode 100644 index 0000000..66c72ea --- /dev/null +++ b/src/Http/Detection/ClientIpResolver.php @@ -0,0 +1,111 @@ += 0; --$index) { + $forwardedAddress = self::normalizeIP($forwardedFor[$index]); + if ($forwardedAddress === false) { + return $remoteAddress; + } + + if (!self::isTrustedProxy($forwardedAddress)) { + return $forwardedAddress; + } + } + + return $remoteAddress; + } + + private static function normalizeIP($ip) + { + $ip = trim((string) $ip, " \t\n\r\0\x0B\""); + + if (preg_match('/^\[([^\]]+)\](?::\d+)?$/', $ip, $matches)) { + $ip = $matches[1]; + } elseif (preg_match('/^([^:]+):\d+$/', $ip, $matches)) { + $ip = $matches[1]; + } + + return filter_var($ip, FILTER_VALIDATE_IP); + } + + private static function isTrustedProxy($ip) + { + foreach (self::$trustedProxies as $trustedProxy) { + if (self::isIpInRange($ip, $trustedProxy)) { + return true; + } + } + + return false; + } + + private static function isIpInRange($ip, $range) + { + $range = trim((string) $range); + if (strpos($range, '/') === false) { + return $ip === self::normalizeIP($range); + } + + [$subnet, $prefixLength] = explode('/', $range, 2); + $ipBinary = inet_pton($ip); + $subnetBinary = inet_pton($subnet); + if ($ipBinary === false || $subnetBinary === false || \strlen($ipBinary) !== \strlen($subnetBinary)) { + return false; + } + + $maximumPrefixLength = \strlen($ipBinary) * 8; + if (!ctype_digit($prefixLength) || (int) $prefixLength > $maximumPrefixLength) { + return false; + } + + $prefixLength = (int) $prefixLength; + $fullBytes = intdiv($prefixLength, 8); + $remainingBits = $prefixLength % 8; + + if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($subnetBinary, 0, $fullBytes)) { + return false; + } + + if ($remainingBits === 0) { + return true; + } + + $mask = (0xFF << (8 - $remainingBits)) & 0xFF; + + return (\ord($ipBinary[$fullBytes]) & $mask) === (\ord($subnetBinary[$fullBytes]) & $mask); + } +} diff --git a/src/Http/Detection/UserAgent.php b/src/Http/Detection/UserAgent.php new file mode 100644 index 0000000..0d414e2 --- /dev/null +++ b/src/Http/Detection/UserAgent.php @@ -0,0 +1,274 @@ +ID) : null; $userDetails['time'] = current_time('mysql'); diff --git a/src/Http/RequestType.php b/src/Http/RequestType.php index af4ac6f..2ffed43 100644 --- a/src/Http/RequestType.php +++ b/src/Http/RequestType.php @@ -14,6 +14,8 @@ final class RequestType const FRONTEND = 'frontend'; + const STATIC_PAGE = 'static'; + /** * Returns if request is for specific $type. * diff --git a/src/Http/Response.php b/src/Http/Response.php index b8de559..4f6fe38 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,33 +2,46 @@ namespace BitApps\WPKit\Http; +use InvalidArgumentException; + final class Response { const SUCCESS = 'success'; const ERROR = 'error'; - private static $_instance; + private static $_current; - private static $_message; + private $_message; - private static $_status; + private $_status; - private static $_code; + private $_code; - private static $_data; + private $_data; - private static $_httpStatus; + private $_httpStatus; - private static $_headers = []; + private $_headers = []; public static function instance() { - if (\is_null(self::$_instance)) { - self::$_instance = new self(); - } + return self::current(); + } + + public static function reset() + { + return self::$_current = new self(); + } - return self::$_instance; + /** + * Makes an existing response the current one so the static accessors read it back. + * + * @return self + */ + public static function adopt(self $response) + { + return self::$_current = $response; } /** @@ -41,12 +54,13 @@ public static function instance() */ public static function success($data, $httpStatus = 200) { - self::$_data = $data; - self::$_status = self::SUCCESS; + $current = self::current(); + $current->_data = $data; + $current->_status = self::SUCCESS; - self::$_httpStatus = $httpStatus; + $current->_httpStatus = $httpStatus; - return self::instance(); + return $current; } /** @@ -59,12 +73,13 @@ public static function success($data, $httpStatus = 200) */ public static function error($data, $httpStatus = 400) { - self::$_data = $data; - self::$_status = self::ERROR; + $current = self::current(); + $current->_data = $data; + $current->_status = self::ERROR; - self::$_httpStatus = $httpStatus; + $current->_httpStatus = $httpStatus; - return self::instance(); + return $current; } /** @@ -74,7 +89,7 @@ public static function error($data, $httpStatus = 400) */ public static function getData() { - return self::$_data; + return self::current()->_data; } /** @@ -84,7 +99,7 @@ public static function getData() */ public static function getStatus() { - return self::$_status; + return self::current()->_status; } /** @@ -96,9 +111,10 @@ public static function getStatus() */ public static function message($message) { - self::$_message = $message; + $current = self::current(); + $current->_message = $message; - return self::instance(); + return $current; } /** @@ -108,7 +124,7 @@ public static function message($message) */ public static function getMessage() { - return self::$_message; + return self::current()->_message; } /** @@ -120,9 +136,10 @@ public static function getMessage() */ public static function code($code) { - self::$_code = $code; + $current = self::current(); + $current->_code = $code; - return self::instance(); + return $current; } /** @@ -132,11 +149,12 @@ public static function code($code) */ public static function getCode() { - if (!isset(self::$_code)) { - return strtoupper(self::$_status); + $current = self::current(); + if (!isset($current->_code)) { + return isset($current->_status) ? strtoupper($current->_status) : null; } - return self::$_code; + return $current->_code; } /** @@ -148,9 +166,10 @@ public static function getCode() */ public static function httpStatus($code) { - self::$_httpStatus = $code; + $current = self::current(); + $current->_httpStatus = $code; - return self::instance(); + return $current; } /** @@ -160,9 +179,10 @@ public static function httpStatus($code) */ public static function getHttpStatusCode() { - $statusCode = self::$_httpStatus; + $current = self::current(); + $statusCode = $current->_httpStatus; if (!$statusCode) { - $statusCode = self::ERROR === self::$_status ? 400 : 200; + $statusCode = self::ERROR === $current->_status ? 400 : 200; } return $statusCode; @@ -171,15 +191,22 @@ public static function getHttpStatusCode() /** * Sets http headers for response. * - * @param string $headers http headers to return on response + * @param array $headers http headers to return on response * * @return self */ public static function headers($headers) { - self::$_headers = $headers; + if (!\is_array($headers)) { + throw new InvalidArgumentException('Response headers must be an array.'); + } - return self::instance(); + self::current()->_headers = []; + foreach ($headers as $header => $value) { + self::header($header, $value); + } + + return self::current(); } /** @@ -192,9 +219,18 @@ public static function headers($headers) */ public static function header($header, $value) { - self::$_headers[$header] = $value; + if (!\is_string($header) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $header) !== 1) { + throw new InvalidArgumentException('Invalid response header name.'); + } + + if (!\is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) { + throw new InvalidArgumentException('Invalid response header value.'); + } + + $current = self::current(); + $current->_headers[$header] = $value; - return self::instance(); + return $current; } /** @@ -204,6 +240,15 @@ public static function header($header, $value) */ public static function getHeaders() { - return self::$_headers; + return self::current()->_headers; + } + + private static function current() + { + if (\is_null(self::$_current)) { + self::$_current = new self(); + } + + return self::$_current; } } diff --git a/src/Http/Router/Emitter/AjaxResponseEmitter.php b/src/Http/Router/Emitter/AjaxResponseEmitter.php new file mode 100644 index 0000000..163a29b --- /dev/null +++ b/src/Http/Router/Emitter/AjaxResponseEmitter.php @@ -0,0 +1,17 @@ + $value) { + header("{$key}: {$value}"); + } + } + + wp_send_json($response['data'], $response['http_status']); + } +} diff --git a/src/Http/Router/Emitter/ApiResponseEmitter.php b/src/Http/Router/Emitter/ApiResponseEmitter.php new file mode 100644 index 0000000..85fd21e --- /dev/null +++ b/src/Http/Router/Emitter/ApiResponseEmitter.php @@ -0,0 +1,18 @@ +set_data($response['data']); + $restResponse->set_status($response['http_status']); + $restResponse->set_headers($response['headers']); + + return $restResponse; + } +} diff --git a/src/Http/Router/Emitter/ResponseEmitter.php b/src/Http/Router/Emitter/ResponseEmitter.php new file mode 100644 index 0000000..61c5ae2 --- /dev/null +++ b/src/Http/Router/Emitter/ResponseEmitter.php @@ -0,0 +1,15 @@ + array, 'http_status' => int, 'headers' => array] + * + * @return mixed + */ + public function emit(array $response); +} diff --git a/src/Http/Router/Emitter/StaticResponseEmitter.php b/src/Http/Router/Emitter/StaticResponseEmitter.php new file mode 100644 index 0000000..4c13ce7 --- /dev/null +++ b/src/Http/Router/Emitter/StaticResponseEmitter.php @@ -0,0 +1,12 @@ +_middlewares = $middlewares; + $this->_resolved = []; + } + + public function resolve($name) + { + if (isset($this->_resolved[$name])) { + return $this->_resolved[$name]; + } + + if (!isset($this->_middlewares[$name])) { + throw new MiddlewareConfigurationException("Middleware [{$name}] is not registered."); + } + + $middleware = $this->_middlewares[$name]; + if (!class_exists($middleware)) { + throw new MiddlewareConfigurationException("Middleware class [{$middleware}] does not exist."); + } + + if (!method_exists($middleware, 'handle')) { + throw new MiddlewareConfigurationException("Middleware [{$middleware}] must define handle()."); + } + + return $this->_resolved[$name] = new $middleware(); + } +} diff --git a/src/Http/Router/ResponseEnvelope.php b/src/Http/Router/ResponseEnvelope.php new file mode 100644 index 0000000..4977113 --- /dev/null +++ b/src/Http/Router/ResponseEnvelope.php @@ -0,0 +1,62 @@ + array, 'http_status' => int, 'headers' => array] + */ + public static function build($result, $bufferedOutput = '') + { + $response = self::normalize($result); + + $data = []; + if ($status = $response->getStatus()) { + $data['status'] = $status; + } + + if ($message = $response->getMessage()) { + $data['message'] = $message; + } + + if ($code = $response->getCode()) { + $data['code'] = $code; + } + + $data['data'] = $response->getData(); + if (!empty($bufferedOutput)) { + $data['additional'] = $bufferedOutput; + } + + return [ + 'data' => $data, + 'http_status' => $response->getHttpStatusCode(), + 'headers' => $response->getHeaders(), + ]; + } + + private static function normalize($result) + { + if (is_wp_error($result)) { + return Response::error($result->get_error_data()) + ->code($result->get_error_code()) + ->message($result->get_error_message()); + } + + if (!$result instanceof Response) { + return Response::success($result)->code('SUCCESS'); + } + + // the static accessors read the current holder, so make the passed response current + return Response::adopt($result); + } +} diff --git a/src/Http/Router/RewriteRuleSet.php b/src/Http/Router/RewriteRuleSet.php new file mode 100644 index 0000000..2a6e0cc --- /dev/null +++ b/src/Http/Router/RewriteRuleSet.php @@ -0,0 +1,58 @@ +_pageName = trim($pageName, '/'); + } + + public function addPath(string $path) + { + if (empty($this->_rules)) { + $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; + } + + preg_match_all(RoutePattern::PLACEHOLDER, $path, $regexMatched); + $path = $this->_pageName . '/' . $path . '/'; + $matchCount = 1; + $previousPath = "^{$this->_pageName}/?$"; + + foreach ($regexMatched[0] as $param) { + $param = trim($param, '{}?'); + $pathChunk = substr($path, 0, strpos($path, "{{$param}}")); + $pathChunkWithoutParam = '^' . $pathChunk . '?$'; + $pathChunkWithParam = '^' . $pathChunk . '([^/]+)/?$'; + + $path = str_replace("{{$param}}", '([^/]+)', $path); + if (!isset($this->_rules[$pathChunkWithoutParam]) && strpos($pathChunkWithoutParam, '([^/]+)')) { + $previousPath = trim(substr($pathChunkWithoutParam, 0, strpos($pathChunkWithoutParam, '([^/]+)') + \strlen('([^/]+)') + 1), '/') . '/?$'; + } + $this->_rules[$pathChunkWithoutParam] = $this->_rules[$previousPath]; + $this->_rules[$pathChunkWithParam] = $this->_rules[$pathChunkWithoutParam] . "&{$param}=\$matches[{$matchCount}]"; + ++$matchCount; + $this->_queryVars[] = $param; + } + } + + public function rules() + { + return $this->_rules; + } + + public function queryVars() + { + return array_values(array_unique($this->_queryVars)); + } +} diff --git a/src/Http/Router/RouteBlockedException.php b/src/Http/Router/RouteBlockedException.php new file mode 100644 index 0000000..86aa14a --- /dev/null +++ b/src/Http/Router/RouteBlockedException.php @@ -0,0 +1,24 @@ +_response = $response; + } + + public function getResponse() + { + return $this->_response; + } +} diff --git a/src/Http/Router/RoutePattern.php b/src/Http/Router/RoutePattern.php new file mode 100644 index 0000000..4bc8683 --- /dev/null +++ b/src/Http/Router/RoutePattern.php @@ -0,0 +1,58 @@ + string, 'params' => [name => ['required' => bool]]]; null when the path has no placeholders + */ + public static function compile(string $path) + { + if (preg_match_all(self::PLACEHOLDER, $path, $matched, PREG_OFFSET_CAPTURE) === false || empty($matched[0])) { + return; + } + + $regex = ''; + $params = []; + $cursor = 0; + foreach ($matched[0] as [$placeholder, $offset]) { + $name = trim($placeholder, '{}?'); + if (preg_match('/^[A-Za-z_]\w*$/', $name) !== 1) { + throw new InvalidArgumentException("Invalid route parameter name [{$name}] in path [{$path}]."); + } + + if (isset($params[$name])) { + throw new InvalidArgumentException("Duplicate route parameter [{$name}] in path [{$path}]."); + } + + $required = strpos($placeholder, '?') === false; + $params[$name] = ['required' => $required]; + $literal = substr($path, $cursor, $offset - $cursor); + $cursor = $offset + \strlen($placeholder); + + if (!$required && substr($literal, -1) === '/') { + // fold the separator into the optional group so "entries" matches "entries/{slug?}" + $regex .= self::quoteLiteral(substr($literal, 0, -1)) . "(?:\\/(?P<{$name}>[^\\/]+))?"; + + continue; + } + + $regex .= self::quoteLiteral($literal) . "(?P<{$name}>[^\\/]+)" . ($required ? '' : '?'); + } + + return ['regex' => $regex . self::quoteLiteral(substr($path, $cursor)), 'params' => $params]; + } + + private static function quoteLiteral($literal) + { + return str_replace('/', '\/', preg_quote($literal, '~')); + } +} diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php index 55daf65..577f292 100644 --- a/src/Http/Router/RouteRegister.php +++ b/src/Http/Router/RouteRegister.php @@ -7,12 +7,11 @@ use BitApps\WPKit\Http\Response; use Closure; +use ReflectionFunction; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; use WP_REST_Request; -use WP_REST_Response; - final class RouteRegister { @@ -26,23 +25,12 @@ final class RouteRegister private $_routeBase; - private $_routeParams; - - private $_routeParamValues; - - private $_regex; + private $_routeParams = []; - private $_regexMatched; + private $_routeParamValues = []; private $_middleware = []; - /** - * Instance of rest request. - * - * @var WP_REST_Response - */ - private $_restResponse; - /** * Instance of rest request. * @@ -59,6 +47,12 @@ final class RouteRegister private $_response = []; + private $_bufferLevel; + + private $_compiled; + + private $_compiledDone = false; + public function __construct(RouteBase $routeBase) { $this->_routeBase = $routeBase; @@ -106,7 +100,8 @@ public function getAction() public function path($path) { - $this->_path = $path; + $this->_path = $path; + $this->_compiledDone = false; return $this; } @@ -140,11 +135,7 @@ public function isTokenIgnored() public function regex() { - if (isset($this->_regex)) { - return $this->_regex; - } - - if (!$this->hasRegex()) { + if ($this->compiledPattern() === null) { return false; } @@ -153,14 +144,7 @@ public function regex() public function hasRegex() { - if (!isset($this->_path) || (isset($this->_regexMatched) && empty($this->_regexMatched[0]))) { - return false; - } - - return !( - preg_match_all('/\{\w+\??\}\??/', $this->_path, $this->_regexMatched) === false - || empty($this->_regexMatched[0]) - ); + return $this->compiledPattern() !== null; } public function getMiddleware() @@ -177,27 +161,15 @@ public function middleware() public function handleMiddleware() { - if (empty($middlewares = $this->getMiddleware())) { - return; - } + try { + $this->runMiddlewares(); + } catch (RouteBlockedException $exception) { + $this->recordBlock($exception); - $router = $this->getRouter(); - foreach ($middlewares as $middleware) { - $middlewareData = explode(':', (string) $middleware); - $middleware = $middlewareData[0]; - $params = []; - if (isset($middlewareData[1])) { - $params = explode(',', (string) $middlewareData[1]); - } - - if ( - ($middlewareObj = $router->getRegisteredMiddleware($middleware)) - && ($response = $this->invokeAsReflection($middlewareObj, 'handle', $params)) !== true - ) { - $this->setResponse($response); - $this->sendResponse(); - } + return false; } + + return true; } public function getRoutePrefix() @@ -235,45 +207,13 @@ public function getRouteParamValue($name) public function getParamValue(ReflectionParameter $param) { - $value = !$param->isOptional() && $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + try { + return $this->resolveParamValue($param); + } catch (RouteBlockedException $exception) { + $this->recordBlock($exception); - $paramName = $param->getName(); - if ($isRouteParam = $this->getRouteParamValue($paramName)) { - $value = $isRouteParam; - } - - if (!$type = $param->getType()) { - return $value; - } - - if ($type instanceof ReflectionNamedType) { - $type = $type->getName(); - } else { - $type = (string) $type; - } - - if (!class_exists($type)) { - return $value; - } - - if ($type === Request::class || is_subclass_of($type, Request::class)) { - $this->setRequest($type); - $value = $this->getRequest(); - } elseif ($isRouteParam && $value === $isRouteParam && method_exists($type, '__construct')) { - $constructor = new ReflectionMethod($type, '__construct'); - if ($constructor->getNumberOfParameters() === 1) { - $parameter = $constructor->getParameters()[0]; - if (!$parameter->hasType()) { - $value = new $type($value); - } elseif (method_exists($type, 'query')) { - $value = $type::query()->find($value); - } - } - } elseif (!$param->isOptional()) { - $value = new $type(); + return; } - - return $value; } public function getRouteParamValues() @@ -288,11 +228,13 @@ public function getRouteParamValues() */ public function getRequest() { - if (!isset($this->_request)) { - $this->setRequest(); - } + try { + return $this->resolveRequest(); + } catch (RouteBlockedException $exception) { + $this->recordBlock($exception); - return $this->_request; + return $this->_request; + } } /** @@ -317,24 +259,103 @@ public function getRouterType() public function handleRequest() { + $this->_response = []; + unset($this->_request, $this->_restRequest); + Response::reset(); + + $this->_bufferLevel = ob_get_level(); ob_start(); if (\func_num_args() && ($apiRequest = \func_get_args()[0]) instanceof WP_REST_Request) { $this->setRestRequest($apiRequest); } - $this->handleMiddleware(); - $this->handleAction($this); + + try { + $this->runMiddlewares(); + $this->handleAction(); + } catch (RouteBlockedException $exception) { + $this->recordBlock($exception); + } finally { + // an action throwing anything else must not leak the buffer we opened + $this->collectBufferedOutput(); + } return $this->sendResponse(); } - private function setRestRequest(WP_REST_Request $request) + private function resolveParamValue(ReflectionParameter $param) { - $this->_restRequest = $request; + $value = !$param->isOptional() && $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + + $paramName = $param->getName(); + if ($isRouteParam = $this->getRouteParamValue($paramName)) { + $value = $isRouteParam; + } + + if (!$type = $param->getType()) { + return $value; + } + + if ($type instanceof ReflectionNamedType) { + $type = $type->getName(); + } else { + $type = (string) $type; + } + + if (!class_exists($type)) { + return $value; + } + + if ($type === Request::class || is_subclass_of($type, Request::class)) { + $this->setRequest($type); + $value = $this->resolveRequest(); + } elseif ($isRouteParam && $value === $isRouteParam && method_exists($type, '__construct')) { + $constructor = new ReflectionMethod($type, '__construct'); + if ($constructor->getNumberOfParameters() === 1) { + $parameter = $constructor->getParameters()[0]; + if (!$parameter->hasType()) { + $value = new $type($value); + } elseif (method_exists($type, 'query')) { + $value = $type::query()->find($value); + } + } + } elseif (!$param->isOptional()) { + $value = new $type(); + } + + return $value; } - private function getRestRequest() + private function runMiddlewares() { - return $this->_restRequest; + if (empty($middlewares = $this->getMiddleware())) { + return; + } + + $router = $this->getRouter(); + foreach ($middlewares as $middleware) { + $middlewareData = explode(':', (string) $middleware); + $middleware = $middlewareData[0]; + $params = []; + if (isset($middlewareData[1])) { + $params = explode(',', (string) $middlewareData[1]); + } + + try { + $middlewareObj = $router->getRegisteredMiddleware($middleware); + } catch (MiddlewareConfigurationException $exception) { + throw new RouteBlockedException(Response::error([], 500)->code('MIDDLEWARE_CONFIGURATION')->message('Route middleware is not configured')); + } + + $response = $this->invokeAsReflection($middlewareObj, 'handle', $params); + if ($response !== true) { + $this->block($response); + } + } + } + + private function setRestRequest(WP_REST_Request $request) + { + $this->_restRequest = $request; } /** @@ -342,6 +363,15 @@ private function getRestRequest() * * @param Request $request */ + private function resolveRequest() + { + if (!isset($this->_request)) { + $this->setRequest(); + } + + return $this->_request; + } + private function setRequest($request = null) { if ($request === null) { @@ -368,20 +398,18 @@ private function authorize() $message = $this->_request->failedAuthorizationMessage(); } - $this->setResponse( + $this->block( Response::error([]) ->code('NOT_AUTHORIZED') ->message($message) ); - - $this->sendResponse(); } } private function validate() { if (method_exists($this->_request, 'rules')) { - $messages = []; + $messages = []; $attributes = []; if (method_exists($this->_request, 'messages')) { @@ -400,8 +428,7 @@ private function validate() ); if ($validation->fails()) { - $this->setResponse(Response::error($validation->errors())->code('VALIDATION')); - $this->sendResponse(); + $this->block(Response::error($validation->errors())->code('VALIDATION')); } } } @@ -417,20 +444,27 @@ private function register($method, $path, $action) private function makeRegex() { - $path = str_replace('/', '\/', $this->_path); - foreach ($this->_regexMatched[0] as $param) { - $name = trim($param, '{}?'); - $required = true; - if (strpos($param, '?')) { - $required = false; - } + $compiled = $this->compiledPattern(); + foreach ($compiled['params'] as $name => $attribute) { + $this->setRouteParam($name, $attribute); + } + + return $compiled['regex']; + } - $this->setRouteParam($name, ['required' => $required]); - $regexToSet = "(?P<{$name}>[^\\/]+)" . ($required ? '' : '?'); - $path = str_replace($param, $regexToSet, $path); + /** + * Compiles the route path once and memoizes it (null when the path has no placeholders). + * + * @return null|array + */ + private function compiledPattern() + { + if (!$this->_compiledDone) { + $this->_compiled = isset($this->_path) ? RoutePattern::compile($this->_path) : null; + $this->_compiledDone = true; } - return $path; + return $this->_compiled; } private function setRouteParam($name, $attribute) @@ -448,21 +482,17 @@ private function handleAction() } else { $response = Response::message('Route action doesn\'t exists'); } + $this->setResponse($response); } - private function invokeAsReflectionFunction(Closure|string $method, $params = []) + /** + * @param Closure|string $method + */ + private function invokeAsReflectionFunction($method) { $reflectionFunction = new ReflectionFunction($method); - $reflectionParams = $reflectionFunction->getParameters(); - - $params = $this->processParameters($reflectionParams, $params); - - if (RequestType::is(RequestType::API) && isset($this->_restResponse)) { - // maybe failed at middleware,authorization or validation - - return Response::instance(); - } + $params = $this->processParameters($reflectionFunction->getParameters()); return $reflectionFunction->invoke(...$params); } @@ -471,7 +501,7 @@ private function processParameters($reflectionParams, $params = []) { $requestParams = []; foreach ($reflectionParams as $param) { - $requestParams[] = $this->getParamValue($param); + $requestParams[] = $this->resolveParamValue($param); } return array_merge($requestParams, $params); @@ -493,85 +523,55 @@ private function invokeAsReflection($class, $method, $params = []) $params = $this->processParameters($reflectionParams, $params); - if (RequestType::is(RequestType::API) && isset($this->_restResponse)) { - // maybe failed at middleware,authorization or validation - - return Response::instance(); - } - return $reflectionMethod->invoke($reflectionMethod->isStatic() ? null : new $class(), ...$params); } - private function setResponse($response) + private function block($response) { - if (is_wp_error($response)) { - $response = Response::error($response->get_error_data()) - ->code($response->get_error_code()) - ->message($response->get_error_message()); - } elseif (!$response instanceof Response) { - $response = Response::success($response)->code('SUCCESS'); - } - - if ($status = $response->getStatus()) { - $responseData['status'] = $status; - } + throw new RouteBlockedException($response); + } - if ($message = $response->getMessage()) { - $responseData['message'] = $message; - } + private function recordBlock(RouteBlockedException $exception) + { + $this->setResponse($exception->getResponse()); + } - if ($code = $response->getCode()) { - $responseData['code'] = $code; + /** + * Captures stray output from the buffer handleRequest() opened; never touches buffers owned by others. + */ + private function collectBufferedOutput() + { + if ($this->_bufferLevel === null || ob_get_level() <= $this->_bufferLevel) { + return ''; } - $responseData['data'] = $response->getData(); - $additional = wp_ob_end_flush_all(); + $this->_bufferLevel = null; - if (!empty($additional)) { - $responseData['additional'] = $additional; - } + return ob_get_clean(); + } - $this->_response = [ - 'data' => $responseData, - 'http_status' => $response->getHttpStatusCode(), - 'headers' => $response->getHeaders(), - ]; + private function setResponse($response) + { + $this->_response = ResponseEnvelope::build($response, $this->collectBufferedOutput()); } private function sendResponse() { - if ($this->getRouterType() === RequestType::API) { - return $this->sendApiResponse(); - } - if ($this->getRouterType() === RequestType::AJAX) { - return $this->sendAjaxResponse(); - } - - // return only data for web routes. Other types handle response themselves. data is html content. - return $this->_response['data']['data']; + return $this->resolveEmitter()->emit($this->_response); } - private function sendApiResponse() + private function resolveEmitter() { - $restResponse = new WP_REST_Response(); - $restResponse->set_data($this->_response['data']); - $restResponse->set_status($this->_response['http_status']); - $restResponse->set_headers($this->_response['headers']); + switch ($this->getRouterType()) { + case RequestType::API: + return new Emitter\ApiResponseEmitter(); - $this->_restResponse = $restResponse; // will be used to return before middleware or action excutes + case RequestType::AJAX: + return new Emitter\AjaxResponseEmitter(); - return $restResponse; - } - - private function sendAjaxResponse() - { - if (!headers_sent() && $this->_response['headers']) { - foreach ($this->_response['headers'] as $key => $value) { - header("{$key}: {$value}"); - } + default: + // static/web plus any custom type: hand the raw action output back to the caller + return new Emitter\StaticResponseEmitter(); } - - wp_send_json($this->_response['data'], $this->_response['http_status']); } } - diff --git a/src/Http/Router/Router.php b/src/Http/Router/Router.php index 72ce136..89fc2d5 100644 --- a/src/Http/Router/Router.php +++ b/src/Http/Router/Router.php @@ -2,15 +2,15 @@ namespace BitApps\WPKit\Http\Router; +use BitApps\WPKit\Http\RequestType; + final class Router { - private $_routes; - - private $_registeredRoutes; + private $_routes = []; - private $_middlewares; + private $_registeredRoutes = []; - private $_registeredMiddlewares; + private $_middlewareRegistry; private $_namespace; @@ -20,13 +20,17 @@ final class Router private static $_instance; + // keyed by type only — two routers of the same type share one slot, last constructed wins + private static $_registry = []; + public function __construct($type, $namespace, $version) { - $this->_routes = []; - $this->_namespace = $namespace; - $this->_version = $version; - $this->_requestType = $type; - self::$_instance = $this; + $this->_namespace = $namespace; + $this->_version = $version; + $this->_requestType = $type; + $this->_middlewareRegistry = new MiddlewareRegistry(); + self::$_instance = $this; + self::$_registry[$type] = $this; } public function getRequestType() @@ -79,13 +83,28 @@ public function getRegisteredRoutes() return $this->_registeredRoutes; } - public static function instance($type = 'ajax', $namespace = null, $version = null) + public static function instance($type = null, $namespace = null, $version = null) { - if (\is_null(self::$_instance)) { - self::$_instance = new self($type, $namespace, $version); + if ($type === null) { + if (\is_null(self::$_instance)) { + self::$_instance = new self(RequestType::AJAX, $namespace, $version); + } + + return self::$_instance; + } + + if (isset(self::$_registry[$type])) { + return self::$_registry[$type]; } - return self::$_instance; + // creates, registers, AND makes the new router current — declare routes before constructing transports + return new self($type, $namespace, $version); + } + + public static function reset() + { + self::$_instance = null; + self::$_registry = []; } public function registerFile($routeFile) @@ -97,10 +116,10 @@ public function registerFile($routeFile) public function register() { - if ($this->getRequestType() === 'ajax') { + if ($this->getRequestType() === RequestType::AJAX) { $ajaxRouter = new AjaxRouter($this); $ajaxRouter->registerRoutes(); - } elseif ($this->getRequestType() === 'api') { + } elseif ($this->getRequestType() === RequestType::API) { $ajaxRouter = new APIRouter($this); $ajaxRouter->registerRoutes(); } @@ -108,17 +127,11 @@ public function register() public function setMiddlewares($middlewares) { - $this->_middlewares = $middlewares; + $this->_middlewareRegistry->register($middlewares); } public function getRegisteredMiddleware($name) { - if (!isset($this->_registeredMiddlewares[$name])) { - $this->_registeredMiddlewares[$name] = isset($this->_middlewares[$name]) - && class_exists($this->_middlewares[$name]) - && method_exists($this->_middlewares[$name], 'handle') ? new $this->_middlewares[$name]() : null; - } - - return $this->_registeredMiddlewares[$name]; + return $this->_middlewareRegistry->resolve($name); } } diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 7c0095c..c84aaee 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -2,8 +2,7 @@ namespace BitApps\WPKit\Http\Router; -use BitApps\WPKit\Http\Router\Router; -use BitApps\WPKit\Http\Router\RouteRegister; +use BitApps\WPKit\Http\RequestType; if (!\defined('ABSPATH')) { exit; @@ -13,19 +12,19 @@ class StaticRouter { private Router $router; + private string $pageName; + private array $rewriteRules = []; private array $queryVars = []; private string $content; - public function __construct( - private string $pageName, - private string $activationHook, - private string $deactivationHook - ) { - $this->router = Router::instance('static', $this->pageName); - $this->registerHooks(); + public function __construct(string $pageName, string $activationHook, string $deactivationHook, ?Router $router = null) + { + $this->pageName = trim($pageName, '/'); + $this->router = $router ?: Router::instance(RequestType::STATIC_PAGE, $this->pageName); + $this->registerHooks($activationHook, $deactivationHook); } public function flushOnActivate() @@ -47,41 +46,23 @@ public function registerRewriteRules() return; } - foreach ($this->rewriteRules as $regex => $query) { add_rewrite_rule($regex, $query, 'top'); } + + $this->maybeFlushRewriteRules(); } public function addQueryVars($vars) { - if (empty($this->rewriteRules)) { - return $vars; - } - $this->maybeFlashRewriteRules(); - $uniqueQueryVars = array_unique($this->queryVars); - - return array_merge($vars, $uniqueQueryVars); + return array_merge($vars, $this->queryVars); } public function handleRequest() { - $requestPath = sanitize_url($_SERVER['REQUEST_URI']) ?? ''; - $pageName = trim($this->pageName, '/'); + $requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); foreach ($this->router->getRoutes() as $route) { - /** - * RouteRegister instance to check against. - * - * @var RouteRegister $route - */ - $prefix = $route->getRoutePrefix(); - $path = $pageName . '/' . $prefix; - $path = '/' . trim($path, '/') . '/' . trim($route->getPath(), '/'); - if ($prefix) { - $path = $prefix . '/' . $path; - } - if ($this->isRouteMatched($path, $requestPath)) { - $this->setRouteParameters($route, $requestPath); + if ($this->isRouteMatched($route, $requestPath)) { // this filter needs to be added here to avoid affecting other routes add_filter('the_content', [$this, 'renderContent']); @@ -114,35 +95,33 @@ public static function isRewriteExists(?string $path = '', ?array $rewriteRules } $rules = get_option('rewrite_rules'); - $ignorePatterns = ['(.?.+?)(?:/([0-9]+))?/?$', '([^/]+)(?:/([0-9]+))?/?$']; - $rulesToCheck = $path ? ['^' . trim($path, '/')] : array_keys($rewriteRules); + if (!$rules) { + return false; + } - if ($rules) { - $patterns = array_keys($rules); - foreach ($patterns as $pattern) { - if (!\in_array($pattern, $ignorePatterns, true) && \in_array($pattern, $rulesToCheck, true)) { - return true; - } + $rulesToCheck = $path ? ['^' . trim($path, '/')] : array_keys($rewriteRules); + foreach ($rulesToCheck as $rule) { + if (isset($rules[$rule])) { + return true; } } return false; } - public function maybeFlashRewriteRules() + public function maybeFlushRewriteRules() { - if (empty($this->rewriteRules) || $this->isRewriteExists(rewriteRules: $this->rewriteRules)) { - // error_log('Rewrite rules already exist, skipping flush.'); + if (empty($this->rewriteRules) || self::isRewriteExists('', $this->rewriteRules)) { return; } flush_rewrite_rules(); } - private function registerHooks() + private function registerHooks(string $activationHook, string $deactivationHook) { - add_action($this->activationHook, [$this, 'flushOnDeactivate']); - add_action($this->deactivationHook, [$this, 'flushOnActivate']); + add_action($activationHook, [$this, 'flushOnActivate']); + add_action($deactivationHook, [$this, 'flushOnDeactivate']); add_action('init', [$this, 'registerRewriteRules']); add_action('query_vars', [$this, 'addQueryVars']); add_action('template_redirect', [$this, 'handleRequest']); @@ -150,88 +129,39 @@ private function registerHooks() private function processRoutes() { - $routes = $this->router->getRoutes(); - - foreach ($routes as $route) { - /** - * RouteRegister instance to process. - * - * @var RouteRegister $route - */ - $path = $route->getPath(); - $prefix = $route->getRoutePrefix(); - - if ($prefix) { - $path = $prefix . '/' . $path; - } - - $this->makeRewriteRuleForPath($path); + $ruleSet = new RewriteRuleSet($this->pageName); + foreach ($this->router->getRoutes() as $route) { + $ruleSet->addPath($this->routePath($route)); } + + $this->rewriteRules = $ruleSet->rules(); + $this->queryVars = $ruleSet->queryVars(); } - private function isRouteMatched($routePath, $requestPath) + private function routePath(RouteRegister $route): string { - // Replace route parameters with regex pattern for matching - $pattern = preg_replace('/\{(\w+)\}/', '([^/]+)', $routePath); - $pattern = '^' . $pattern . '/?$'; + $prefix = trim((string) $route->getRoutePrefix(), '/'); + $path = trim((string) $route->getPath(), '/'); - return preg_match('~' . $pattern . '~', $requestPath); + return $prefix === '' ? $path : $prefix . '/' . $path; } - private function setRouteParameters(RouteRegister $route, $requestPath) + private function isRouteMatched(RouteRegister $route, string $requestPath): bool { - $path = $route->getPath(); - $prefix = $route->getRoutePrefix(); - - if ($prefix) { - $path = $prefix . '/' . $path; - } + $path = $this->pageName . '/' . $this->routePath($route); + $compiled = RoutePattern::compile($path); + $pattern = $compiled === null ? preg_quote($path, '~') : $compiled['regex']; - $cleanPath = trim($path, '/'); - - preg_match_all('/\{(\w+)\}/', $cleanPath, $matches); - $routeParams = $matches[1]; - - if (empty($routeParams)) { - return; + if (!preg_match('~^/' . $pattern . '/?$~', $requestPath, $matches)) { + return false; } - $regex = '~^' . trim($this->pageName, '/') . '/' . preg_replace('/\{(\w+)\}/', '([^/]+)', $cleanPath) . '~'; - if (preg_match($regex, trim($requestPath, '/'), $matchedValues)) { - // Skip the full match at index 0 - array_shift($matchedValues); - - foreach ($routeParams as $i => $param) { - if (isset($matchedValues[$i])) { - $route->setRouteParamValue($param, $matchedValues[$i]); - } + foreach ($matches as $param => $value) { + if (\is_string($param)) { + $route->setRouteParamValue($param, $value); } } - } - private function makeRewriteRuleForPath(string $path) - { - preg_match_all('/\{\w+\??\}\??/', $path, $regexMatched); - $pagename = trim($this->pageName, '/'); - $path = $pagename . '/' . trim($path, '/') . '/'; - $this->rewriteRules = []; - $this->rewriteRules["^{$pagename}/?$"] = "index.php?pagename={$pagename}"; - $matchCount = 1; - $previousPath = "^{$pagename}/?$"; - while ($param = array_shift($regexMatched[0])) { - $param = trim($param, '{}?'); - $pathChunk = substr($path, 0, strpos($path, "{{$param}}")); - $pathChunkWithoutParam = '^' . $pathChunk . '?$'; - $pathChunkWitParam = '^' . $pathChunk . '([^/]+)/?$'; - - $path = str_replace("{{$param}}", '([^/]+)', $path); - if (!isset($this->rewriteRules[$pathChunkWithoutParam]) && strpos($pathChunkWithoutParam, '([^/]+)')) { - $previousPath = trim(substr($pathChunkWithoutParam, 0, strpos($pathChunkWithoutParam, '([^/]+)') + \strlen('([^/]+)') + 1), '/') . '/?$'; - } - $this->rewriteRules[$pathChunkWithoutParam] = $this->rewriteRules[$previousPath]; - $this->rewriteRules[$pathChunkWitParam] = $this->rewriteRules[$pathChunkWithoutParam] . "&{$param}=\$matches[{$matchCount}]"; - ++$matchCount; - $this->queryVars[] = $param; - } + return true; } } diff --git a/src/Installer.php b/src/Installer.php index 188b00d..42a7198 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -112,7 +112,7 @@ public function checkRequirements() // Str From WP install script wp_die( esc_html( - sprintf( + \sprintf( // translators: 1: Current PHP version, 2: Version required by the uploaded plugin. 'The PHP version on your server is %1$s, however the uploaded plugin requires %2$s.', PHP_VERSION, @@ -126,7 +126,7 @@ public function checkRequirements() if (version_compare(get_bloginfo('version'), $this->_requirements['wp'], '<')) { wp_die( esc_html( - sprintf( + \sprintf( // translators: 1: Current WordPress version, 2: Version required by the uploaded plugin. 'Your WordPress version is %1$s, however the uploaded plugin requires %2$s.', get_bloginfo('version'), diff --git a/src/Migration/Migration.php b/src/Migration/Migration.php index cf4ad8f..a19bdfe 100644 --- a/src/Migration/Migration.php +++ b/src/Migration/Migration.php @@ -1,4 +1,5 @@ ['name' => 'Ada']]; + + assertSameValue('Ada', Arr::get($values, 'user.name'), 'nested get changed'); + assertSameValue(true, Arr::has($values, 'user.name'), 'nested has changed'); + + Arr::set($values, 'user.role', 'admin'); + assertSameValue('admin', Arr::get($values, 'user.role'), 'nested set changed'); + + Arr::forget($values, 'user.name'); + assertSameValue(false, Arr::has($values, 'user.name'), 'nested forget changed'); + } + + public function testArrayHelperAddDoesNotOverwriteExistingValues(): void + { + $values = ['user' => ['name' => 'Ada']]; + + $values = Arr::add($values, 'user.name', 'Grace'); + $values = Arr::add($values, 'user.role', 'admin'); + + assertSameValue( + ['user' => ['name' => 'Ada', 'role' => 'admin']], + $values, + 'conditional add behavior changed', + ); + } + + public function testArrayHelperDotAndFlattenRetainTheirDistinctShapes(): void + { + $values = ['user' => ['name' => 'Ada'], 'roles' => ['admin', ['editor']]]; + + assertSameValue( + ['user.name' => 'Ada', 'roles.0' => 'admin', 'roles.1.0' => 'editor'], + Arr::dot($values), + 'dot flattening changed', + ); + assertSameValue(['Ada', 'admin', 'editor'], Arr::flatten($values), 'value flattening changed'); + } + + public function testArrayHelperCollectionSelectionKeepsKeysAndValues(): void + { + $values = ['one' => 1, 'two' => 2, 'three' => 3]; + + assertSameValue(['one' => 1, 'three' => 3], Arr::only($values, ['one', 'three']), 'only changed'); + assertSameValue(['one' => 1, 'three' => 3], Arr::except($values, 'two'), 'except changed'); + assertSameValue([['one', 'two', 'three'], [1, 2, 3]], Arr::divide($values), 'divide changed'); + } + + public function testArrayHelperPullReturnsAndRemovesANestedValue(): void + { + $values = ['user' => ['name' => 'Ada', 'role' => 'admin']]; + + $role = Arr::pull($values, 'user.role'); + + assertSameValue('admin', $role, 'pull return value changed'); + assertSameValue(['user' => ['name' => 'Ada']], $values, 'pull mutation changed'); + } + + public function testArrayHelperFirstLastAndWherePreserveCallbackSemantics(): void + { + $values = [1, 2, 3, 4]; + $even = function ($value) { + return $value % 2 === 0; + }; + + assertSameValue(2, Arr::first($values, $even), 'first callback behavior changed'); + assertSameValue(4, Arr::last($values, $even), 'last callback behavior changed'); + assertSameValue([1 => 2, 3 => 4], Arr::where($values, $even), 'where key preservation changed'); + } + + public function testArrayHelperPluckSupportsNestedValuesAndKeys(): void + { + $values = [ + ['user' => ['id' => 'a', 'name' => 'Ada']], + ['user' => ['id' => 'g', 'name' => 'Grace']], + ]; + + assertSameValue( + ['a' => 'Ada', 'g' => 'Grace'], + Arr::pluck($values, 'user.name', 'user.id'), + 'nested pluck changed', + ); + } + + public function testArrayHelperWildcardDataAccessReturnsMatchingValues(): void + { + $values = [ + 'users' => [ + ['name' => 'Ada'], + ['name' => 'Grace'], + ], + ]; + + assertSameValue(['Ada', 'Grace'], Arr::dataGet($values, 'users.*.name'), 'wildcard data access changed'); + assertSameValue('fallback', Arr::dataGet($values, 'missing', 'fallback'), 'data access default changed'); + } + + public function testArrayHelperCrossJoinReturnsEveryOrderedCombination(): void + { + assertSameValue( + [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']], + Arr::crossJoin([1, 2], ['a', 'b']), + 'cross join changed', + ); + } + + public function testArrayHelperQueryAndCSSClassRenderingRemainDeterministic(): void + { + assertSameValue('search=hello%20world&page=2', Arr::query(['search' => 'hello world', 'page' => 2]), 'query encoding changed'); + assertSameValue( + 'base active', + Arr::toCssClasses(['base', 'active' => true, 'disabled' => false]), + 'conditional CSS classes changed', + ); + } + + public function testArrayHelperWrapAndDeferredDefaultsRetainValueSemantics(): void + { + assertSameValue([], Arr::wrap(null), 'null wrapping changed'); + assertSameValue(['value'], Arr::wrap('value'), 'scalar wrapping changed'); + assertSameValue('resolved:x', Arr::value(function ($suffix) { + return 'resolved:' . $suffix; + }, 'x'), 'deferred value invocation changed'); + } + + public function testArrayHelperRequestingTooManyRandomValuesFailsClearly(): void + { + assertThrows( + InvalidArgumentException::class, + function () { + Arr::random([1], 2); + }, + 'invalid random selection was accepted', + ); + } +} diff --git a/tests/Helpers/DateTimeTest.php b/tests/Helpers/DateTimeTest.php new file mode 100644 index 0000000..4fe6e9b --- /dev/null +++ b/tests/Helpers/DateTimeTest.php @@ -0,0 +1,82 @@ +getDate('2024-02-03 14:05:06', 'Y-m-d H:i:s', $utc, 'd/m/Y', $utc), + 'date formatting changed', + ); + assertSameValue( + '02:05 PM', + $helper->getTime('2024-02-03 14:05:06', 'Y-m-d H:i:s', $utc, 'h:i A', $utc), + 'time formatting changed', + ); + } + + public function testDateTimeHelperDayAndMonthNamesRemainSelectable(): void + { + $helper = new DateTimeHelper(); + $utc = new DateTimeZone('UTC'); + + assertSameValue( + 'Saturday', + $helper->getDay('full-name', '2024-02-03', 'Y-m-d', $utc, $utc), + 'full day name changed', + ); + assertSameValue( + 'Feb', + $helper->getMonth('short-name', '2024-02-03', 'Y-m-d', $utc, $utc), + 'short month name changed', + ); + } + + public function testDateTimeHelperTimezoneConversionIsAppliedBeforeFormatting(): void + { + $helper = new DateTimeHelper(); + + assertSameValue( + '2024-02-03 20:05', + $helper->getFormated( + '2024-02-03 14:05:00', + 'Y-m-d H:i:s', + new DateTimeZone('UTC'), + 'Y-m-d H:i', + new DateTimeZone('Asia/Dhaka'), + ), + 'timezone conversion changed', + ); + } + + public function testDateTimeHelperUnicodeDateFormatsConvertToPHPFormats(): void + { + $helper = new DateTimeHelper(); + + assertSameValue('d/m/Y', $helper->getUnicodeToPhpFormat('custom', 'dd/MM/yyyy'), 'Unicode conversion changed'); + } + + public function testDateTimeHelperWordPressTimezoneRemainsExposedAsDateTimeZone(): void + { + WpKitTestState::$options['timezone_string'] = 'Asia/Dhaka'; + + assertSameValue('Asia/Dhaka', DateTimeHelper::wp_timezone_string(), 'WordPress timezone string changed'); + assertSameValue('Asia/Dhaka', DateTimeHelper::wp_timezone()->getName(), 'WordPress timezone object changed'); + } +} diff --git a/tests/Helpers/JsonAndSlugTest.php b/tests/Helpers/JsonAndSlugTest.php new file mode 100644 index 0000000..3c0542a --- /dev/null +++ b/tests/Helpers/JsonAndSlugTest.php @@ -0,0 +1,53 @@ + 'Ada']), 'JSON encoding changed'); + assertSameValue('{"name":"Ada"}', JSON::maybeEncode(['name' => 'Ada']), 'conditional JSON encoding changed'); + assertSameValue('plain', JSON::maybeEncode('plain'), 'scalar JSON pass-through changed'); + } + + public function testJSONHelperDecodingHonorsAssociativeMode(): void + { + assertSameValue(['name' => 'Ada'], JSON::decode('{"name":"Ada"}', true), 'associative decode changed'); + assertSameValue(['name' => 'Ada'], JSON::maybeDecode('{"name":"Ada"}', true), 'conditional decode changed'); + assertSameValue(['already' => 'decoded'], JSON::maybeDecode(['already' => 'decoded'], true), 'decoded input changed'); + } + + public function testJSONHelperValidJSONIsReturnedAndInvalidJSONIsRejected(): void + { + $decoded = JSON::is('{"name":"Ada"}', true); + + assertSameValue(['name' => 'Ada'], $decoded, 'valid JSON detection changed'); + assertSameValue(false, JSON::is('{invalid'), 'invalid JSON was accepted'); + } + + public function testJSONConfigurationAssociativeDecodingPreferenceIsMutable(): void + { + JsonConfig::setDecodeAsArray(false); + assertSameValue(false, JsonConfig::decodeAsArray(), 'JSON decode preference did not change'); + + JsonConfig::setDecodeAsArray(true); + assertSameValue(true, JsonConfig::decodeAsArray(), 'JSON decode preference did not restore'); + } + + public function testSlugHelperPunctuationAndWhitespaceNormalizeToLowercaseHyphens(): void + { + assertSameValue('hello-wp-kit', Slug::generate(' Hello, WP Kit! '), 'slug normalization changed'); + assertSameValue('already-clean', Slug::generate('Already--Clean'), 'repeated separator normalization changed'); + } +} diff --git a/tests/Http/ClientIpResolverTest.php b/tests/Http/ClientIpResolverTest.php new file mode 100644 index 0000000..9c1ee7e --- /dev/null +++ b/tests/Http/ClientIpResolverTest.php @@ -0,0 +1,104 @@ +request('https://example.com', 'GET', []); + + assertSameValue(['safe' => 1, 'unsafe' => 0], WpKitTestState::$httpCalls, 'safe request function was not used'); + assertSameValue(true, $response->safe, 'JSON response was not decoded'); + } + + public function testHTTPClientUnsafeRemoteRequestsRequireExplicitOptIn(): void + { + $client = new HttpClient(); + $client->allowUnsafeUrls(); + $response = $client->request('http://internal.example', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'unsafe opt-in was ignored'); + assertSameValue(false, $response->safe, 'unsafe transport response was not returned'); + } + + public function testHTTPClientWordPressErrorsAreReturnedUnchanged(): void + { + $client = new HttpClient(); + $response = $client->request('https://example.com/error', 'GET', []); + + assertInstanceOf(FakeWpError::class, $response, 'WordPress error was decoded or replaced'); + } + + public function testHTTPClientConstructorAppliesSupportedDefaults(): void + { + $client = new HttpClient([ + 'base_uri' => 'https://example.com/', + 'content_type' => 'text/plain', + 'headers' => ['X-Test' => 'value'], + 'body' => ['body' => 'value'], + 'form_params' => ['form' => 'value'], + 'json' => ['json' => 'value'], + 'multipart' => [['name' => 'part', 'contents' => 'value']], + 'allow_unsafe_urls' => true, + ]); + + assertSameValue('https://example.com/', $client->getBaseUri(), 'base URI default was not applied'); + assertSameValue(['value'], $client->getHeader('X-Test'), 'header default was not applied'); + assertSameValue(['body' => 'value'], $client->getBody(), 'body default was not applied'); + assertSameValue(['form' => 'value'], $client->getFormParams(), 'form default was not applied'); + assertSameValue(['json' => 'value'], $client->getJson(), 'JSON default was not applied'); + assertSameValue( + [['name' => 'part', 'contents' => 'value']], + $client->getMultipart(), + 'multipart default was not applied', + ); + } + + public function testHTTPClientFluentConfigurationRetainsRequestValues(): void + { + $client = new HttpClient(); + $result = $client + ->setBaseUri('https://example.com/') + ->setOptions(['redirection' => 2]) + ->setParams(['internal' => 'value']) + ->setQueryParams(['page' => 1]) + ->setQueryParam('tag', 'one') + ->setQueryParam('tag', 'two') + ->setBody('raw-body'); + + assertSameValue($client, $result, 'fluent configuration stopped returning the client'); + assertSameValue(['redirection' => 2], $client->getOptions(), 'options changed'); + assertSameValue('value', $client->getParam('internal'), 'generic parameter changed'); + assertSameValue(false, $client->getParam('missing'), 'missing generic parameter contract changed'); + assertSameValue(['page' => 1, 'tag' => ['one', 'two']], $client->getQueryParams(), 'query parameters changed'); + assertSameValue('raw-body', $client->getBody(), 'body changed'); + } + + public function testHTTPClientExplicitRequestOptionsOverrideDefaults(): void + { + $client = new HttpClient(); + $client->setHeaders(['X-Default' => 'default']); + $client->request( + 'https://example.com', + 'patch', + 'payload', + ['X-Request' => 'request'], + ['timeout' => 5], + ); + + assertSameValue('https://example.com', WpKitTestState::$lastHttpRequest['url'], 'request URL changed'); + assertSameValue( + [ + 'method' => 'PATCH', + 'headers' => ['X-Request' => 'request'], + 'body' => 'payload', + 'timeout' => 5, + ], + WpKitTestState::$lastHttpRequest['options'], + 'request option precedence changed', + ); + assertSameValue(['X-Transport' => 'safe'], $client->getResponseHeaders(), 'response headers changed'); + assertSameValue(200, $client->getResponseCode(), 'response code changed'); + } + + public function testHTTPClientPayloadBuilderPassesStringsThroughUnchanged(): void + { + $client = new HttpClient(); + $client->setBody('raw-body'); + + assertSameValue('raw-body', $client->getPreparedPayload(), 'string body was transformed'); + } + + public function testHTTPClientPayloadBuilderMergesStructuredBodiesAsJSON(): void + { + $client = new HttpClient(); + $client + ->setBody(['body' => 1]) + ->setJson(['json' => 2]) + ->setFormParams(['form' => 3]); + + assertSameValue( + '{"body":1,"json":2,"form":3}', + $client->getPreparedPayload(), + 'structured payload merge order changed', + ); + } + + public function testHTTPClientExplicitMultipartBoundariesRemainStable(): void + { + $client = new HttpClient(); + + assertSameValue($client, $client->setBoundary('contract'), 'boundary setter stopped being fluent'); + assertSameValue('-------contract', $client->getBoundary(), 'explicit boundary changed'); + } + + public function testHTTPClientUnsupportedDynamicMethodsFailClearly(): void + { + $client = new HttpClient(); + + assertThrows( + BadMethodCallException::class, + function () use ($client) { + $client->trace('/resource'); + }, + 'unsupported dynamic method was accepted', + ); + } +} diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php new file mode 100644 index 0000000..4150bb6 --- /dev/null +++ b/tests/Http/RequestTest.php @@ -0,0 +1,107 @@ + 'value', 'shared' => 'query']; + $_POST = ['body' => 'value', 'shared' => 'body']; + $_FILES = ['upload' => ['name' => 'contract.txt']]; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $request = new Request(); + + assertSameValue(['query' => 'value', 'shared' => 'query'], $request->queryParams(), 'query values changed'); + assertSameValue(['body' => 'value', 'shared' => 'body'], $request->body(), 'body values changed'); + assertSameValue($_FILES, $request->files(), 'file collection changed'); + assertSameValue('query', $request->get('shared'), 'request source precedence changed'); + } + + public function testRequestAccessorsMagicPropertiesAndArrayAccessShareAttributes(): void + { + $_GET = ['name' => 'Ada']; + $request = new Request(); + + assertSameValue('Ada', $request->get('name'), 'get accessor changed'); + assertSameValue('fallback', $request->get('missing', 'fallback'), 'default accessor changed'); + assertSameValue(true, $request->has('name'), 'has accessor changed'); + assertSameValue('Ada', $request->name, 'magic getter changed'); + assertSameValue('Ada', $request['name'], 'array getter changed'); + + $request->role = 'admin'; + $request['active'] = true; + unset($request->role); + + assertSameValue(false, isset($request->role), 'magic unset changed'); + assertSameValue(true, $request['active'], 'array setter changed'); + assertSameValue(['name' => 'Ada', 'active' => true], $request->jsonSerialize(), 'serialized attributes changed'); + } + + public function testRequestExceptReturnsAFilteredCopy(): void + { + $_GET = ['one' => 1, 'two' => 2, 'three' => 3]; + $request = new Request(); + + assertSameValue(['one' => 1, 'three' => 3], $request->except('two'), 'except result changed'); + assertSameValue(2, $request->get('two'), 'except mutated the request'); + } + + public function testRequestMethodAndContentTypeReflectServerMetadata(): void + { + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $request = new Request(); + + assertSameValue('PATCH', $request->method(), 'request method changed'); + assertSameValue('json', $request->contentType(), 'content type changed'); + } + + public function testRequestRESTRequestReplacesGlobalsAndRetainsSourcePrecedence(): void + { + $_GET = ['old' => 'query']; + $_POST = ['old_body' => 'body']; + $request = new Request(); + $request->setApiRequest( + new WP_REST_Request( + ['body' => 'value', 'shared' => 'body'], + ['query' => 'value', 'shared' => 'query'], + [], + ['json' => 'value'], + ), + ); + + assertSameValue( + [ + 'query' => 'value', + 'shared' => 'query', + 'body' => 'value', + 'json' => 'value', + ], + $request->all(), + 'REST request hydration or precedence changed', + ); + } + + public function testRequestSuccessfulValidationReturnsValidatedValues(): void + { + $_POST = ['name' => 'Ada']; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $request = new Request(); + + $validated = $request->validate(['name' => ['required']]); + + assertSameValue(['name' => 'Ada'], $validated, 'successful validation result changed'); + assertSameValue(null, WpKitTestState::$sentJson, 'successful validation emitted an error response'); + } +} diff --git a/tests/Http/ResponseTest.php b/tests/Http/ResponseTest.php new file mode 100644 index 0000000..ead2bbd --- /dev/null +++ b/tests/Http/ResponseTest.php @@ -0,0 +1,128 @@ + 42], 201) + ->message('Created') + ->code('ENTRY_CREATED') + ->header('Location', '/entries/42'); + + assertInstanceOf(Response::class, $response, 'success factory did not return a response'); + assertSameValue(Response::SUCCESS, $response->getStatus(), 'success status changed'); + assertSameValue(['id' => 42], $response->getData(), 'success data changed'); + assertSameValue('Created', $response->getMessage(), 'success message changed'); + assertSameValue('ENTRY_CREATED', $response->getCode(), 'success code changed'); + assertSameValue(201, $response->getHttpStatusCode(), 'success HTTP status changed'); + assertSameValue(['Location' => '/entries/42'], $response->getHeaders(), 'success headers changed'); + } + + public function testResponseErrorFactorySuppliesDefaultMetadata(): void + { + $response = Response::error(['field' => 'invalid']); + + assertSameValue(Response::ERROR, $response->getStatus(), 'error status changed'); + assertSameValue('ERROR', $response->getCode(), 'default error code changed'); + assertSameValue(400, $response->getHttpStatusCode(), 'default error HTTP status changed'); + } + + public function testResponseResetRemovesAllPreviousMetadata(): void + { + Response::error(['old' => true], 409) + ->message('old') + ->code('OLD') + ->header('X-Old', 'yes'); + + $response = Response::reset(); + + assertInstanceOf(Response::class, $response, 'reset did not return a response'); + assertSameValue(null, Response::getStatus(), 'status was not reset'); + assertSameValue(null, Response::getData(), 'data was not reset'); + assertSameValue(null, Response::getMessage(), 'message was not reset'); + assertSameValue([], Response::getHeaders(), 'headers were not reset'); + assertSameValue(200, Response::getHttpStatusCode(), 'reset HTTP status fallback changed'); + } + + public function testResponseResetRotatesToAFreshInstance(): void + { + $before = Response::instance(); + Response::error(['old' => true])->message('old'); + + $after = Response::reset(); + + assertTest($before !== $after, 'reset did not rotate to a fresh response instance'); + assertSameValue(null, $after->getStatus(), 'rotated instance carried stale status'); + } + + public function testResponseHeadersBulkHeadersRequireAnArray(): void + { + assertThrows( + InvalidArgumentException::class, + function () { + Response::headers('X-Test: value'); + }, + 'non-array bulk headers were accepted', + ); + } + + public function testResponseHeadersValidBulkHeadersReplaceTheCollection(): void + { + Response::header('X-Old', 'old'); + $response = Response::headers([ + 'X-Test' => 'value', + 'X-Numeric' => 123, + ]); + + assertInstanceOf(Response::class, $response, 'bulk header setter did not return the response'); + assertSameValue( + ['X-Test' => 'value', 'X-Numeric' => 123], + Response::getHeaders(), + 'bulk headers did not replace the collection', + ); + } + + public function testResponseHeadersNamesRejectControlCharacters(): void + { + assertThrows( + InvalidArgumentException::class, + function () { + Response::header("X-Test\r\nInjected", 'value'); + }, + 'invalid header name was accepted', + ); + } + + public function testResponseHeadersValuesRejectControlCharacters(): void + { + assertThrows( + InvalidArgumentException::class, + function () { + Response::header('X-Test', "value\r\nInjected: yes"); + }, + 'invalid header value was accepted', + ); + } + + public function testResponseHeadersNonScalarValuesAreRejected(): void + { + assertThrows( + InvalidArgumentException::class, + function () { + Response::header('X-Test', ['invalid']); + }, + 'non-scalar header value was accepted', + ); + } +} diff --git a/tests/Http/UserAgentTest.php b/tests/Http/UserAgentTest.php new file mode 100644 index 0000000..582d387 --- /dev/null +++ b/tests/Http/UserAgentTest.php @@ -0,0 +1,28 @@ + __DIR__ . '/../Fixtures/Migrations/', + 'migrations' => ['ContractMigration'], + ]; +} + +function contractInstallerRequirements() +{ + return [ + 'oldVersion' => '1.0.0', + 'version' => '2.0.0', + 'php' => '8.0', + 'wp' => '6.0', + 'multisite' => true, + ]; +} + +function resetContractMigrationCalls() +{ + if (!class_exists('ContractMigration')) { + MigrationHelper::getMigrationInstances(contractMigrationConfiguration()); + } + + ContractMigration::$upCalls = 0; + ContractMigration::$downCalls = 0; +} + +/** + * @internal + * + * @coversNothing + */ +final class MigrationAndInstallerTest extends TestCase +{ + public function testMigrationHelperConfiguredClassesAreLoadedAndInstantiated(): void + { + $instances = MigrationHelper::getMigrationInstances(contractMigrationConfiguration()); + + assertSameValue(1, \count($instances), 'migration instance count changed'); + assertInstanceOf(ContractMigration::class, $instances[0], 'migration class changed'); + } + + public function testMigrationHelperMigrateAndDropInvokeLifecycleMethods(): void + { + resetContractMigrationCalls(); + + MigrationHelper::migrate(contractMigrationConfiguration()); + MigrationHelper::drop(contractMigrationConfiguration()); + + assertSameValue(1, ContractMigration::$upCalls, 'migration up invocation changed'); + assertSameValue(1, ContractMigration::$downCalls, 'migration down invocation changed'); + } + + public function testInstallerRegisterConnectsConfiguredLifecycleHooks(): void + { + $installer = new Installer( + contractInstallerRequirements(), + ['activate' => 'plugin_activate', 'uninstall' => 'plugin_uninstall'], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->register(); + + assertTest(isset(WpKitTestState::$actions['plugin_activate']), 'activation hook registration changed'); + assertTest(isset(WpKitTestState::$actions['plugin_uninstall']), 'uninstall hook registration changed'); + } + + public function testInstallerVersionUpgradesRunMigrationsOnOneSite(): void + { + resetContractMigrationCalls(); + $installer = new Installer( + contractInstallerRequirements(), + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->activateOnSingleSite(); + + assertSameValue(1, ContractMigration::$upCalls, 'single-site migration changed'); + } + + public function testInstallerNetworkActivationVisitsAndRestoresEverySite(): void + { + resetContractMigrationCalls(); + WpKitTestState::$sites = [11, 12]; + $installer = new Installer( + contractInstallerRequirements(), + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->activate(true); + + assertSameValue([11, 12], WpKitTestState::$switchedBlogs, 'network site traversal changed'); + assertSameValue(2, WpKitTestState::$restoredBlogs, 'network blog restoration changed'); + assertSameValue(2, ContractMigration::$upCalls, 'network migration count changed'); + } + + public function testInstallerUninstallDropsConfiguredMigrations(): void + { + resetContractMigrationCalls(); + new Installer( + contractInstallerRequirements(), + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + Installer::uninstall(); + + assertSameValue(1, ContractMigration::$downCalls, 'single-site uninstall changed'); + } + + public function testInstallerUnmetPHPRequirementsTerminateActivation(): void + { + $requirements = contractInstallerRequirements(); + $requirements['php'] = '99.0'; + $installer = new Installer( + $requirements, + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + assertThrows( + WpDieException::class, + function () use ($installer) { + $installer->checkRequirements(); + }, + 'unmet PHP requirement was accepted', + ); + } + + public function testInstallerUnmetWordPressRequirementsTerminateActivation(): void + { + $requirements = contractInstallerRequirements(); + $requirements['wp'] = '99.0'; + $installer = new Installer( + $requirements, + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + assertThrows( + WpDieException::class, + function () use ($installer) { + $installer->checkRequirements(); + }, + 'unmet WordPress requirement was accepted', + ); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..f2bfd15 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,24 @@ +# WPKit compatibility tests + +The tests are grouped by public feature so internal architecture can change +without changing the behavior observed by consuming plugins. + +PHPUnit discovers every `*Test.php` feature suite through `phpunit.xml`. Shared +WordPress doubles live in `bootstrap.php`, and `TestCase.php` resets their state +before every test. + +The contract tests intentionally do not preserve known implementation defects, +including process-global router identity, implicit authorization, broken +automatic multipart boundaries, and static rewrite-rule loss. Those behaviors +must be corrected rather than treated as compatibility requirements. + +Run the suite and package coverage report with: + +```bash +composer test +composer coverage +``` + +The coverage command runs the PHPUnit suite under PHPDBG and retains the focused +100% executable-line gate for selected HTTP hardening methods. It is not +package-wide coverage. diff --git a/tests/Router/DispatchTest.php b/tests/Router/DispatchTest.php new file mode 100644 index 0000000..29992cd --- /dev/null +++ b/tests/Router/DispatchTest.php @@ -0,0 +1,290 @@ + ['required'], + ]; + } + + public function messages() + { + return [ + 'required_field.required' => 'Required field is missing', + ]; + } + + public function attributes() + { + return [ + 'required_field' => 'Required field', + ]; + } +} + +final class ContractValidationProtectedAction +{ + public static $executed = false; + + public function run(ContractInvalidRequest $request) + { + self::$executed = true; + + return 'executed'; + } +} + +final class ContractRestRequestAction +{ + public static $values; + + public function run(Request $request) + { + self::$values = $request->all(); + + return 'executed'; + } +} + +final class ContractAuthorizedRequest extends Request +{ + public function authorize() + { + return true; + } + + public function rules() + { + return ['required_field' => ['required']]; + } +} + +final class ContractAuthorizedAction +{ + public function run(ContractAuthorizedRequest $request) + { + return 'authorized-ok'; + } +} + +/** + * @internal + * + * @coversNothing + */ +final class DispatchTest extends TestCase +{ + public function testRouteDispatchAuthorizedValidRequestReachesItsAction(): void + { + $_GET['required_field'] = 'present'; + $_POST['required_field'] = 'present'; + $_REQUEST['required_field'] = 'present'; + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('open', [ContractAuthorizedAction::class, 'run']); + + assertSameValue('authorized-ok', $route->handleRequest(), 'authorized valid request did not reach its action'); + } + + public function testRouteDispatchDirectParamResolutionDoesNotLeakInternalExceptions(): void + { + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get( + 'protected', + [ContractAuthorizationProtectedAction::class, 'run'], + ); + + $value = $route->getParamValue(new ReflectionParameter([ContractAuthorizationProtectedAction::class, 'run'], 0)); + + assertSameValue(null, $value, 'denied direct param resolution did not return null'); + assertSameValue('NOT_AUTHORIZED', Response::getCode(), 'denial response was not recorded for direct callers'); + } + + public function testRouteDispatchActionExceptionDoesNotLeakTheOutputBuffer(): void + { + $baseline = ob_get_level(); + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('boom', function () { + throw new RuntimeException('boom'); + }); + + assertThrows( + RuntimeException::class, + function () use ($route) { + $route->handleRequest(); + }, + 'action exception did not propagate', + ); + assertSameValue($baseline, ob_get_level(), 'output buffer leaked after an action exception'); + } + + public function testRouteDispatchFailedAuthorizationStopsActionAndDependencyResolution(): void + { + ContractAuthorizationProtectedAction::$executed = false; + ContractSideEffectDependency::$constructed = false; + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get( + 'protected', + [ContractAuthorizationProtectedAction::class, 'run'], + ); + + $route->handleRequest(); + + assertTest(!ContractAuthorizationProtectedAction::$executed, 'protected action executed after authorization failure'); + assertTest(!ContractSideEffectDependency::$constructed, 'dependency constructed after authorization failure'); + assertSameValue('Custom authorization failure', Response::getMessage(), 'custom authorization message was lost'); + } + + public function testRouteDispatchFailedValidationPreventsActionExecution(): void + { + ContractValidationProtectedAction::$executed = false; + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('protected', [ContractValidationProtectedAction::class, 'run']); + + $route->handleRequest(); + + assertTest(!ContractValidationProtectedAction::$executed, 'protected action executed after validation failure'); + assertSameValue('VALIDATION', Response::getCode(), 'validation error code was not returned'); + } + + public function testRouteDispatchDirectRequestAccessBuildsTheBaseRequest(): void + { + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('request', function () { + return 'executed'; + }); + + assertInstanceOf(Request::class, $route->getRequest(), 'base request was not created'); + } + + public function testRouteDispatchRESTDataHydratesAnInjectedRequest(): void + { + ContractRestRequestAction::$values = null; + new Router('api', 'contract-test', 'v1'); + $route = (new RouteBase())->get('rest-request', [ContractRestRequestAction::class, 'run']); + $request = new WP_REST_Request( + ['body_value' => 'body'], + ['query_value' => 'query'], + ['route_value' => 'route'], + ['json_value' => 'json'], + ); + + $response = $route->handleRequest($request); + + assertSameValue( + [ + 'query_value' => 'query', + 'body_value' => 'body', + 'json_value' => 'json', + 'route_value' => 'route', + ], + ContractRestRequestAction::$values, + 'REST values did not hydrate the injected request', + ); + assertInstanceOf(WP_REST_Response::class, $response, 'API dispatch did not return a REST response'); + } + + public function testRouteDispatchMissingHandlersProduceAnErrorResponse(): void + { + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('missing-action', ['MissingContractAction', 'run']); + + $route->handleRequest(); + + assertSameValue('Route action doesn\'t exists', Response::getMessage(), 'missing action response was not generated'); + } + + public function testRouteDispatchClosureAuthorizationFailurePreventsInvocation(): void + { + $closureExecuted = false; + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get( + 'closure-denied', + function (ContractDeniedRequest $request) use (&$closureExecuted) { + $closureExecuted = true; + + return 'executed'; + }, + ); + + $route->handleRequest(); + + assertTest(!$closureExecuted, 'closure executed after authorization failed'); + } + + public function testRouteDispatchClosureActionsReturnTheirData(): void + { + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('closure', function () { + return ['result' => 'executed']; + }); + + $response = $route->handleRequest(); + + assertSameValue(['result' => 'executed'], $response, 'static dispatch did not return action data'); + } + + public function testRouteDispatchResponseMetadataResetsBetweenRequests(): void + { + Response::error([])->message('stale error'); + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('response-reset', function () { + return 'executed'; + }); + + $route->handleRequest(); + + assertSameValue(null, Response::getMessage(), 'stale response message leaked into the next request'); + } + + public function testRouteDispatchActionOutputIsCapturedAsAdditionalResponseData(): void + { + new Router('api', 'contract-test', 'v1'); + $route = (new RouteBase())->get('output', function () { + echo 'diagnostic'; + + return 'executed'; + }); + + $response = $route->handleRequest(new WP_REST_Request()); + $data = $response->get_data(); + + assertSameValue('diagnostic', $data['additional'], 'buffered action output was not preserved'); + assertSameValue('executed', $data['data'], 'action response data was not preserved'); + } +} diff --git a/tests/Router/MiddlewareTest.php b/tests/Router/MiddlewareTest.php new file mode 100644 index 0000000..57aed7a --- /dev/null +++ b/tests/Router/MiddlewareTest.php @@ -0,0 +1,220 @@ + 'denied'], 403); + } +} + +final class ContractAllowingMiddleware +{ + public static $role; + + public function handle(Request $request, $role) + { + self::$role = $role; + + return true; + } +} + +final class ContractMiddlewareWithoutHandle +{ +} + +final class ContractMiddlewareProtectedAction +{ + public static $executed = false; + + public function run() + { + self::$executed = true; + + return 'executed'; + } +} + +final class ContractDeniedRequest extends Request +{ + public function authorize() + { + return false; + } + + public function failedAuthorizationMessage() + { + return 'Custom authorization failure'; + } +} + +final class ContractRequestDenyingMiddleware +{ + public static $executed = false; + + public function handle(ContractDeniedRequest $request) + { + self::$executed = true; + + return true; + } +} + +/** + * @internal + * + * @coversNothing + */ +final class MiddlewareTest extends TestCase +{ + public function testRouterMiddlewareDenyingMiddlewarePreventsActionExecution(): void + { + ContractMiddlewareProtectedAction::$executed = false; + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['deny' => ContractDenyingMiddleware::class]); + $route = (new RouteBase()) + ->middleware('deny') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + $route->handleRequest(); + + assertTest(!ContractMiddlewareProtectedAction::$executed, 'protected action executed after middleware denial'); + } + + public function testRouterMiddlewareParametersReachAnAllowingMiddleware(): void + { + ContractMiddlewareProtectedAction::$executed = false; + ContractAllowingMiddleware::$role = null; + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['allow' => ContractAllowingMiddleware::class]); + $route = (new RouteBase()) + ->middleware('allow:administrator') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + $route->handleRequest(); + + assertSameValue('administrator', ContractAllowingMiddleware::$role, 'middleware parameter was not passed'); + assertTest(ContractMiddlewareProtectedAction::$executed, 'allowed action did not execute'); + } + + public function testRouterMiddlewareDeniedRequestInjectionIsTerminal(): void + { + ContractMiddlewareProtectedAction::$executed = false; + ContractRequestDenyingMiddleware::$executed = false; + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['request-deny' => ContractRequestDenyingMiddleware::class]); + $route = (new RouteBase()) + ->middleware('request-deny') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + $route->handleRequest(); + + assertTest(!ContractRequestDenyingMiddleware::$executed, 'middleware executed after request authorization failed'); + assertTest(!ContractMiddlewareProtectedAction::$executed, 'action executed after request authorization failed'); + } + + public function testRouterMiddlewareDenialStopsLaterMiddlewareImmediately(): void + { + ContractMiddlewareProtectedAction::$executed = false; + ContractAllowingMiddleware::$role = null; + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares([ + 'deny' => ContractDenyingMiddleware::class, + 'allow' => ContractAllowingMiddleware::class, + ]); + $route = (new RouteBase()) + ->middleware('deny', 'allow:administrator') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + $route->handleRequest(); + + assertSameValue(null, ContractAllowingMiddleware::$role, 'middleware after a denial was still entered'); + assertTest(!ContractMiddlewareProtectedAction::$executed, 'action executed after middleware denial'); + } + + public function testRouterMiddlewareMissingAliasesFailClosed(): void + { + ContractMiddlewareProtectedAction::$executed = false; + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares([]); + $route = (new RouteBase()) + ->middleware('missing') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + $route->handleRequest(); + + assertTest(!ContractMiddlewareProtectedAction::$executed, 'action executed without configured middleware'); + assertSameValue('MIDDLEWARE_CONFIGURATION', Response::getCode(), 'configuration error code was not returned'); + } + + public function testRouterMiddlewareDirectDenialReturnsFalseWithoutThrowing(): void + { + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['deny' => ContractDenyingMiddleware::class]); + $route = (new RouteBase()) + ->middleware('deny') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + assertSameValue(false, $route->handleMiddleware(), 'direct middleware denial did not report false'); + } + + public function testRouterMiddlewareDirectAllowanceReturnsTrue(): void + { + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['allow' => ContractAllowingMiddleware::class]); + $route = (new RouteBase()) + ->middleware('allow:admin') + ->get('protected', [ContractMiddlewareProtectedAction::class, 'run']); + + assertSameValue(true, $route->handleMiddleware(), 'direct middleware allowance did not report true'); + } + + public function testRouterMiddlewareMissingClassesAreRejected(): void + { + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['missing-class' => 'MissingContractMiddlewareClass']); + + assertThrows( + RuntimeException::class, + function () use ($router) { + $router->getRegisteredMiddleware('missing-class'); + }, + 'missing middleware class was accepted', + ); + } + + public function testRouterMiddlewareClassesWithoutHandleAreRejected(): void + { + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['missing-handle' => ContractMiddlewareWithoutHandle::class]); + + assertThrows( + RuntimeException::class, + function () use ($router) { + $router->getRegisteredMiddleware('missing-handle'); + }, + 'middleware without handle() was accepted', + ); + } + + public function testRouterMiddlewareResolvedInstancesAreCachedPerRouter(): void + { + $router = new Router('static', 'contract-test', null); + $router->setMiddlewares(['allow' => ContractAllowingMiddleware::class]); + + $first = $router->getRegisteredMiddleware('allow'); + $second = $router->getRegisteredMiddleware('allow'); + + assertTest($first === $second, 'middleware resolver returned different instances'); + } +} diff --git a/tests/Router/ResponseEmissionTest.php b/tests/Router/ResponseEmissionTest.php new file mode 100644 index 0000000..a5d258d --- /dev/null +++ b/tests/Router/ResponseEmissionTest.php @@ -0,0 +1,56 @@ +get('entries', static function () { + return ['result' => 'ok']; + }); + + $response = $route->handleRequest(new WP_REST_Request()); + + assertInstanceOf(WP_REST_Response::class, $response, 'api dispatch did not return a WP_REST_Response'); + assertSameValue(['result' => 'ok'], $response->get_data()['data'], 'api envelope data changed'); + assertSameValue(200, $response->get_status(), 'api status changed'); + } + + public function testAjaxDispatchSendsJsonWithStatus(): void + { + new Router('ajax', 'contract-test', 'v1'); + $route = (new RouteBase())->get('entries', static function () { + return Response::success(['result' => 'ok'], 201)->header('X-Test', 'yes'); + }); + + $route->handleRequest(); + + assertSameValue(201, WpKitTestState::$sentJson['status'], 'ajax http status changed'); + assertSameValue(['result' => 'ok'], WpKitTestState::$sentJson['data']['data'], 'ajax payload changed'); + } + + public function testNonStandardRouterTypeFallsBackToRawData(): void + { + new Router('cron', 'contract-test', null); + $route = (new RouteBase())->get('job', static function () { + return 'raw-output'; + }); + + assertSameValue('raw-output', $route->handleRequest(), 'non-api/ajax dispatch no longer returns raw data'); + } +} diff --git a/tests/Router/ResponseEnvelopeTest.php b/tests/Router/ResponseEnvelopeTest.php new file mode 100644 index 0000000..a7e4bba --- /dev/null +++ b/tests/Router/ResponseEnvelopeTest.php @@ -0,0 +1,48 @@ + 'ok']); + + assertSameValue(Response::SUCCESS, $envelope['data']['status'], 'raw value did not become a success envelope'); + assertSameValue('SUCCESS', $envelope['data']['code'], 'success code changed'); + assertSameValue(['result' => 'ok'], $envelope['data']['data'], 'payload changed'); + assertSameValue(200, $envelope['http_status'], 'default success status changed'); + } + + public function testBuildSerializesThePassedResponseNotTheRotatedCurrent(): void + { + $captured = Response::success(['keep' => 'me'])->code('CAPTURED'); + Response::reset(); // rotate current away, e.g. a nested dispatch + + $envelope = ResponseEnvelope::build($captured); + + assertSameValue('CAPTURED', $envelope['data']['code'], 'build serialized the rotated current instead of the passed response'); + assertSameValue(['keep' => 'me'], $envelope['data']['data'], 'build lost the passed response payload'); + } + + public function testBuildKeepsResponseObjectsAndAttachesBufferedOutput(): void + { + $envelope = ResponseEnvelope::build( + Response::error(['field' => 'bad'], 422)->code('VALIDATION'), + 'stray output', + ); + + assertSameValue('VALIDATION', $envelope['data']['code'], 'response code changed'); + assertSameValue(422, $envelope['http_status'], 'http status changed'); + assertSameValue('stray output', $envelope['data']['additional'], 'buffered output was not attached'); + } +} diff --git a/tests/Router/RewriteRuleSetTest.php b/tests/Router/RewriteRuleSetTest.php new file mode 100644 index 0000000..6a5151f --- /dev/null +++ b/tests/Router/RewriteRuleSetTest.php @@ -0,0 +1,50 @@ +addPath('entries/{id}'); + + assertSameValue( + [ + '^landing/?$' => 'index.php?pagename=landing', + '^landing/entries/?$' => 'index.php?pagename=landing', + '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]', + ], + $set->rules(), + 'rewrite rule chain changed', + ); + assertSameValue(['id'], $set->queryVars(), 'query vars changed'); + } + + public function testRulesAccumulateAcrossPaths(): void + { + $set = new RewriteRuleSet('landing'); + $set->addPath('alpha/{a}'); + $set->addPath('beta/{b}'); + + assertTest(isset($set->rules()['^landing/alpha/([^/]+)/?$']), 'first path lost its rule'); + assertTest(isset($set->rules()['^landing/beta/([^/]+)/?$']), 'second path lost its rule'); + assertSameValue(['a', 'b'], $set->queryVars(), 'query vars did not accumulate'); + } + + public function testEmptySetHasNoRulesOrQueryVars(): void + { + $set = new RewriteRuleSet('landing'); + + assertSameValue([], $set->rules(), 'empty set produced rules'); + assertSameValue([], $set->queryVars(), 'empty set produced query vars'); + } +} diff --git a/tests/Router/RouteDefinitionTest.php b/tests/Router/RouteDefinitionTest.php new file mode 100644 index 0000000..7659829 --- /dev/null +++ b/tests/Router/RouteDefinitionTest.php @@ -0,0 +1,139 @@ +getRequestType(), 'request type changed'); + assertSameValue('example', $router->getNamespace(), 'namespace changed'); + assertSameValue('v2/', $router->getVersion(), 'version path changed'); + assertSameValue('example/v2', $router->getAjaxPrefix(), 'AJAX prefix changed'); + } + + public function testRouteDefinitionFluentAttributesAreRetainedByTheRoute(): void + { + $router = new Router('ajax', 'example', 'v1'); + $action = function () { + return 'ok'; + }; + $route = (new RouteBase()) + ->prefix('admin') + ->middleware('auth', 'capability:edit_posts') + ->noAuth() + ->ignoreToken() + ->match('get,post', 'entries', $action) + ->name('entry.index'); + + assertInstanceOf(RouteRegister::class, $route, 'route builder did not return a registered route'); + assertSameValue(['GET', 'POST'], $route->getMethods(), 'HTTP methods were not normalized'); + assertSameValue('entries', $route->getPath(), 'route path changed'); + assertSameValue($action, $route->getAction(), 'route action changed'); + assertSameValue('entry.index', $route->getName(), 'route name changed'); + assertSameValue('admin', $route->getRoutePrefix(), 'route prefix changed'); + assertSameValue(['auth', 'capability:edit_posts'], $route->getMiddleware(), 'route middleware changed'); + assertSameValue(true, $route->isNoAuth(), 'public AJAX flag changed'); + assertSameValue(true, $route->isTokenIgnored(), 'token flag changed'); + assertSameValue([$route], $router->getRoutes(), 'route was not added to its router'); + } + + public function testRouteDefinitionRouteLevelMiddlewareExtendsGroupMiddleware(): void + { + new Router('ajax', 'example', null); + $route = (new RouteBase()) + ->middleware('group-auth') + ->get('entries', function () { + return 'ok'; + }) + ->middleware('route-audit'); + + assertSameValue( + ['group-auth', 'route-audit'], + $route->getMiddleware(), + 'route middleware did not extend base middleware', + ); + } + + public function testRouteDefinitionStaticFacadeRegistersRoutes(): void + { + $router = new Router('ajax', 'example', null); + $route = Route::get('facade', function () { + return 'ok'; + }); + + assertInstanceOf(RouteRegister::class, $route, 'static route facade did not return a route'); + assertSameValue($route, $router->getRoute(0), 'static route facade did not register on the active router'); + } + + public function testRouteDefinitionGroupsApplySharedPrefixAndMiddleware(): void + { + $router = new Router('ajax', 'example', null); + + (new RouteBase()) + ->prefix('admin') + ->middleware('auth') + ->group(function () { + Route::get('entries', function () { + return 'ok'; + }); + }); + + $route = $router->getRoute(0); + assertSameValue('admin', $route->getRoutePrefix(), 'group prefix was not inherited'); + assertSameValue(['auth'], $route->getMiddleware(), 'group middleware was not inherited'); + } + + public function testRouteDefinitionPlaceholdersExposeRegexMetadata(): void + { + new Router('ajax', 'example', null); + $route = (new RouteBase())->get('entries/{id}/{slug?}', function () { + return 'ok'; + }); + + $regex = $route->regex(); + + assertTest(\is_string($regex), 'placeholder route did not produce a regex'); + assertSameValue(['required' => true], $route->getRouteParam('id'), 'required placeholder metadata changed'); + assertSameValue(['required' => false], $route->getRouteParam('slug'), 'optional placeholder metadata changed'); + assertSameValue(false, $route->getRouteParam('missing'), 'missing placeholder contract changed'); + } + + public function testRouteDefinitionRouteParameterValuesRetainScalarZero(): void + { + new Router('ajax', 'example', null); + $route = (new RouteBase())->get('entries/{id}', function () { + return 'ok'; + }); + $route->setRouteParamValue('id', '0'); + + assertSameValue('0', $route->getRouteParamValue('id'), 'zero route parameter was not retained'); + assertSameValue(['id' => '0'], $route->getRouteParamValues(), 'route parameter collection changed'); + } + + public function testRouteDefinitionRegisteredRouteLookupPreservesNames(): void + { + $router = new Router('ajax', 'example', null); + $route = (new RouteBase())->get('entries', function () { + return 'ok'; + }); + $router->addRegisteredRoute('entry.index', $route); + + assertSameValue($route, $router->getRegisteredRoute('entry.index'), 'named route lookup changed'); + assertSameValue(['entry.index' => $route], $router->getRegisteredRoutes(), 'named route collection changed'); + assertSameValue(null, $router->getRegisteredRoute('missing'), 'missing named route contract changed'); + } +} diff --git a/tests/Router/RoutePatternTest.php b/tests/Router/RoutePatternTest.php new file mode 100644 index 0000000..f13745b --- /dev/null +++ b/tests/Router/RoutePatternTest.php @@ -0,0 +1,61 @@ +[^\/]+)\/comments(?:\/(?P[^\/]+))?', $compiled['regex'], 'compiled regex changed'); + assertSameValue( + ['id' => ['required' => true], 'slug' => ['required' => false]], + $compiled['params'], + 'param metadata changed', + ); + } + + public function testCompileQuotesRegexMetacharactersInLiteralSegments(): void + { + $compiled = RoutePattern::compile('files/{name}.json'); + + assertSameValue('files\/(?P[^\/]+)\.json', $compiled['regex'], 'literal metacharacters were not quoted'); + } + + public function testCompileRejectsDuplicateParameterNames(): void + { + assertThrows( + InvalidArgumentException::class, + static function () { + RoutePattern::compile('posts/{id}/related/{id}'); + }, + 'duplicate parameter names were accepted', + ); + } + + public function testCompileRejectsInvalidParameterNames(): void + { + assertThrows( + InvalidArgumentException::class, + static function () { + RoutePattern::compile('posts/{1a}'); + }, + 'digit-leading parameter name was accepted', + ); + } + + public function testCompileReturnsNullWithoutPlaceholders(): void + { + assertSameValue(null, RoutePattern::compile('entries/list'), 'placeholderless path produced a compilation'); + } +} diff --git a/tests/Router/RouterIdentityTest.php b/tests/Router/RouterIdentityTest.php new file mode 100644 index 0000000..059ac7e --- /dev/null +++ b/tests/Router/RouterIdentityTest.php @@ -0,0 +1,58 @@ +getRequestType(), 'instance() returned a router of the wrong type'); + } + + public function testRoutersOfDifferentTypesCoexistByType(): void + { + $ajax = new Router('ajax', 'example', 'v1'); + $api = new Router('api', 'example', 'v1'); + + assertSameValue($ajax, Router::instance('ajax'), 'ajax router was lost from the registry'); + assertSameValue($api, Router::instance('api'), 'api router was lost from the registry'); + } + + public function testRouteDeclarationsBindToTheCurrentRouter(): void + { + $ajax = new Router('ajax', 'example', 'v1'); + (new RouteBase())->get('a', static function () { + return 'a'; + }); + $api = new Router('api', 'example', 'v1'); + (new RouteBase())->get('b', static function () { + return 'b'; + }); + + assertSameValue(1, \count($ajax->getRoutes()), 'route did not bind to the then-current ajax router'); + assertSameValue(1, \count($api->getRoutes()), 'route did not bind to the then-current api router'); + } + + public function testStaticRouterUsesInjectedRouterVerbatim(): void + { + $router = new Router('static', 'landing', null); + new Router('ajax', 'other', null); + $transport = new StaticRouter('landing', 'a_hook', 'd_hook', $router); + + assertSameValue($router, $transport->getRouter(), 'injected router was not used verbatim'); + } +} diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php new file mode 100644 index 0000000..f7ace9a --- /dev/null +++ b/tests/Router/StaticRoutingTest.php @@ -0,0 +1,239 @@ +makeTransport(['entries/{id}' => static function () { + return 'ok'; + }]); + + do_action('init'); + + $rules = WpKitTestState::$rewriteRules; + assertSameValue('index.php?pagename=landing', $rules['^landing/?$']['query'] ?? null, 'page rewrite rule changed'); + assertSameValue('index.php?pagename=landing', $rules['^landing/entries/?$']['query'] ?? null, 'parameterless segment rewrite rule changed'); + assertSameValue('index.php?pagename=landing&id=$matches[1]', $rules['^landing/entries/([^/]+)/?$']['query'] ?? null, 'parameter rewrite rule changed'); + } + + public function testRewriteRulesForMultiParameterRouteChainQueryVars(): void + { + $this->makeTransport(['books/{author}/chapters/{chapter}' => static function () { + return 'ok'; + }]); + + do_action('init'); + + $rules = WpKitTestState::$rewriteRules; + assertSameValue( + 'index.php?pagename=landing&author=$matches[1]', + $rules['^landing/books/([^/]+)/chapters/?$']['query'] ?? null, + 'intermediate rewrite rule lost the first parameter', + ); + assertSameValue( + 'index.php?pagename=landing&author=$matches[1]&chapter=$matches[2]', + $rules['^landing/books/([^/]+)/chapters/([^/]+)/?$']['query'] ?? null, + 'full rewrite rule did not chain both parameters', + ); + } + + public function testInitWithoutRoutesRegistersNoRulesAndSkipsFlush(): void + { + $this->makeTransport(); + + do_action('init'); + + assertSameValue([], WpKitTestState::$rewriteRules, 'rules were registered without any routes'); + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'rewrite rules were flushed without any routes'); + } + + public function testRewriteRulesForEveryRouteAreRegisteredTogether(): void + { + $this->makeTransport([ + 'alpha/{a}' => static function () { + return 'a'; + }, + 'beta/{b}' => static function () { + return 'b'; + }, + ]); + + do_action('init'); + + assertTest(isset(WpKitTestState::$rewriteRules['^landing/alpha/([^/]+)/?$']), 'first route lost its rewrite rule'); + assertTest(isset(WpKitTestState::$rewriteRules['^landing/beta/([^/]+)/?$']), 'second route lost its rewrite rule'); + } + + public function testQueryVarsGainEachRouteParameterOnce(): void + { + $transport = $this->makeTransport([ + 'entries/{id}' => static function () { + return 'a'; + }, + 'authors/{id}' => static function () { + return 'b'; + }, + ]); + + do_action('init'); + + assertSameValue(['page', 'id'], $transport->addQueryVars(['page']), 'route parameters were not merged into query vars exactly once'); + } + + public function testMatchedRequestRendersRouteOutputThroughContentFilter(): void + { + $this->makeTransport(['entries/{id}' => static function ($id) { + return '
entry ' . $id . '
'; + }]); + $_SERVER['REQUEST_URI'] = '/landing/entries/42'; + + do_action('template_redirect'); + + assertSameValue( + '
page
entry 42
', + apply_filters('the_content', '
page
'), + 'matched static route output was not appended to page content', + ); + } + + public function testOptionalParameterRouteCapturesProvidedValue(): void + { + $this->makeTransport(['entries/{slug?}' => static function ($slug) { + return 'entry ' . $slug; + }]); + $_SERVER['REQUEST_URI'] = '/landing/entries/hello'; + + do_action('template_redirect'); + + assertSameValue('page-entry hello', apply_filters('the_content', 'page-'), 'optional param value was not captured'); + } + + public function testOptionalParameterRouteMatchesWithoutValue(): void + { + $this->makeTransport(['entries/{slug?}' => static function ($slug) { + return 'x'; + }]); + $_SERVER['REQUEST_URI'] = '/landing/entries/'; + + do_action('template_redirect'); + + assertTest(isset(WpKitTestState::$filters['the_content']), 'optional-param route did not match without a value'); + } + + public function testOptionalParameterRouteMatchesWithoutTrailingSlash(): void + { + $this->makeTransport(['entries/{slug?}' => static function ($slug) { + return 'x'; + }]); + $_SERVER['REQUEST_URI'] = '/landing/entries'; + + do_action('template_redirect'); + + assertTest(isset(WpKitTestState::$filters['the_content']), 'optional-param route did not match without a trailing slash'); + } + + public function testMatchedRequestIgnoresQueryString(): void + { + $this->makeTransport(['entries/{id}' => static function ($id) { + return 'entry ' . $id; + }]); + $_SERVER['REQUEST_URI'] = '/landing/entries/42?utm_source=mail&preview=1'; + + do_action('template_redirect'); + + assertSameValue('page-entry 42', apply_filters('the_content', 'page-'), 'query string broke static route matching'); + } + + public function testUnmatchedRequestDoesNotTouchPageContent(): void + { + $this->makeTransport(['entries/{id}' => static function () { + return 'x'; + }]); + $_SERVER['REQUEST_URI'] = '/elsewhere/7'; + + do_action('template_redirect'); + + assertTest(!isset(WpKitTestState::$filters['the_content']), 'content filter was registered for an unmatched request'); + } + + public function testActivationRegistersRewriteRulesAndFlushes(): void + { + $this->makeTransport(['entries/{id}' => static function () { + return 'x'; + }]); + + do_action('plugin_activate'); + + assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'activation did not register route rewrite rules before flushing'); + assertTest(WpKitTestState::$rewriteFlushes > 0, 'activation did not flush rewrite rules'); + } + + public function testDeactivationOnlyFlushesRewriteRules(): void + { + $this->makeTransport(['entries/{id}' => static function () { + return 'x'; + }]); + + do_action('plugin_deactivate'); + + assertSameValue([], WpKitTestState::$rewriteRules, 'deactivation registered rewrite rules'); + assertSameValue(1, WpKitTestState::$rewriteFlushes, 'deactivation must flush exactly once'); + } + + public function testInitFlushesOnceWhenStaticRulesAreMissingFromPersistedRules(): void + { + $this->makeTransport(['entries/{id}' => static function () { + return 'x'; + }]); + + do_action('init'); + + assertSameValue(1, WpKitTestState::$rewriteFlushes, 'missing persisted rules did not trigger exactly one flush'); + } + + public function testInitSkipsFlushWhenStaticRulesAlreadyPersisted(): void + { + $this->makeTransport(['entries/{id}' => static function () { + return 'x'; + }]); + WpKitTestState::$options['rewrite_rules'] = [ + '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]', + ]; + + do_action('init'); + + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'already persisted rules still triggered a flush'); + } + + public function testIsRewriteExistsChecksPersistedRulesByPath(): void + { + WpKitTestState::$options['rewrite_rules'] = ['^landing/custom' => 'index.php?pagename=landing']; + + assertTest(StaticRouter::isRewriteExists('landing/custom'), 'persisted path rule was not found'); + assertTest(!StaticRouter::isRewriteExists('landing/other'), 'missing path rule was reported as existing'); + assertTest(!StaticRouter::isRewriteExists(''), 'empty path was reported as existing'); + } + + private function makeTransport(array $routes = []): StaticRouter + { + new Router('static', 'landing', null); + foreach ($routes as $path => $action) { + (new RouteBase())->get($path, $action); + } + + return new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); + } +} diff --git a/tests/Router/TransportRegistrationTest.php b/tests/Router/TransportRegistrationTest.php new file mode 100644 index 0000000..b283cd2 --- /dev/null +++ b/tests/Router/TransportRegistrationTest.php @@ -0,0 +1,114 @@ +prefix('admin') + ->match('GET,POST', 'entries', function () { + return 'ok'; + }); + + (new APIRouter($router))->addRoute($route); + + assertSameValue(1, \count(WpKitTestState::$restRoutes), 'REST route was not registered once'); + $registration = WpKitTestState::$restRoutes[0]; + assertSameValue('example', $registration['namespace'], 'REST namespace changed'); + assertSameValue('v1/admin/entries', $registration['route'], 'REST versioned path changed'); + assertSameValue('GET', $registration['args'][0]['methods'], 'GET method mapping changed'); + assertSameValue('POST', $registration['args'][1]['methods'], 'POST method mapping changed'); + assertSameValue([$route, 'handleRequest'], $registration['args'][0]['callback'], 'REST callback changed'); + assertTest(\is_callable($registration['args'][0]['permission_callback']), 'REST permission callback is not callable'); + } + + public function testRESTTransportWordPressMethodConstantsRemainMapped(): void + { + $router = new Router('api', 'example', 'v1'); + $transport = new APIRouter($router); + + assertSameValue(WP_REST_Server::READABLE, $transport->getMethod('GET'), 'GET mapping changed'); + assertSameValue(WP_REST_Server::CREATABLE, $transport->getMethod('POST'), 'POST mapping changed'); + assertSameValue(WP_REST_Server::EDITABLE, $transport->getMethod('PUT'), 'PUT mapping changed'); + assertSameValue(WP_REST_Server::DELETABLE, $transport->getMethod('DELETE'), 'DELETE mapping changed'); + } + + public function testAJAXTransportAuthenticatedRoutesRegisterTheMatchingAction(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_REQUEST['action'] = 'example/v1/entries/42'; + $router = new Router('ajax', 'example', 'v1'); + $route = (new RouteBase())->post('/entries/{id}', function () { + return 'ok'; + }); + + $transport = new AjaxRouter($router); + $transport->addRoute($route); + + $hook = 'wp_ajax_example/v1/entries/42'; + assertTest(isset(WpKitTestState::$actions[$hook]), 'authenticated AJAX hook was not registered'); + assertTest(!isset(WpKitTestState::$actions['wp_ajax_nopriv_example/v1/entries/42']), 'guest hook was registered'); + assertSameValue('42', $route->getRouteParamValue('id'), 'AJAX path parameter was not captured'); + assertSameValue($route, $transport->currentRoute(), 'current AJAX route lookup changed'); + } + + public function testAJAXTransportPublicRoutesAlsoRegisterTheGuestAction(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_REQUEST['action'] = 'example/public'; + $router = new Router('ajax', 'example', null); + $route = (new RouteBase())->noAuth()->get('/public', function () { + return 'ok'; + }); + + (new AjaxRouter($router))->addRoute($route); + + assertTest(isset(WpKitTestState::$actions['wp_ajax_example/public']), 'authenticated public hook was not registered'); + assertTest(isset(WpKitTestState::$actions['wp_ajax_nopriv_example/public']), 'guest public hook was not registered'); + } + + public function testStaticTransportConstructorRegistersLifecycleAndDispatchHooks(): void + { + new Router('static', 'landing', null); + $transport = new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); + + assertInstanceOf(Router::class, $transport->getRouter(), 'static transport did not expose its router'); + assertTest(isset(WpKitTestState::$actions['plugin_activate']), 'activation hook was not registered'); + assertTest(isset(WpKitTestState::$actions['plugin_deactivate']), 'deactivation hook was not registered'); + assertTest(isset(WpKitTestState::$actions['init']), 'rewrite registration hook was not registered'); + assertTest(isset(WpKitTestState::$actions['query_vars']), 'query variable hook was not registered'); + assertTest(isset(WpKitTestState::$actions['template_redirect']), 'dispatch hook was not registered'); + } + + public function testStaticTransportRenderedRouteOutputIsAppendedToContent(): void + { + new Router('static', 'landing', null); + $transport = new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); + $reflection = new ReflectionProperty($transport, 'content'); + $reflection->setValue($transport, '
route
'); + + assertSameValue( + '
page
route
', + $transport->renderContent('
page
'), + 'static route output composition changed', + ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..bc1a43e --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,15 @@ +getInstance(), 'hook wrapper type changed'); + } + + public function testShortcodeFacadeRegistrationLookupRenderingAndRemovalAreForwarded(): void + { + $callback = function () { + return 'shortcode'; + }; + + Shortcode::addShortcode('contract', $callback); + + assertSameValue(true, Shortcode::shortcodeExists('contract'), 'shortcode lookup changed'); + assertSameValue(true, Shortcode::hasShortcode('[contract]', 'contract'), 'shortcode content detection changed'); + Shortcode::doShortcode('[contract]'); + assertSameValue( + [['content' => '[contract]', 'ignoreHtml' => false]], + WpKitTestState::$shortcodeRenders, + 'shortcode rendering was not forwarded', + ); + + Shortcode::removeShortcode('contract'); + assertSameValue(false, Shortcode::shortcodeExists('contract'), 'shortcode removal changed'); + } + + public function testShortcodeFacadeWrapperRemainsAvailableToInstanceConsumers(): void + { + $shortcode = new Shortcode(); + + assertInstanceOf(ShortcodeWrapper::class, $shortcode->getInstance(), 'shortcode wrapper type changed'); + } + + public function testCapabilitiesFacadeDirectChecksForwardVariadicArguments(): void + { + WpKitTestState::$capabilities['edit_post'] = true; + + assertSameValue(true, Capabilities::check('edit_post', 42), 'capability check changed'); + assertSameValue(false, Capabilities::check('delete_post', 42), 'missing capability changed'); + } + + public function testCapabilitiesFacadeFilteredFallbackCapabilitiesRemainSupported(): void + { + WpKitTestState::$capabilities['manage_contract'] = true; + Hooks::addFilter('edit_contract', function ($default) { + return 'manage_contract'; + }); + + assertSameValue( + true, + Capabilities::filter('edit_contract', 'manage_options'), + 'filtered fallback capability changed', + ); + } + + public function testHTTPFacadeStaticVerbsForwardToTheWordPressClient(): void + { + $response = Http::get('https://example.com', []); + + assertSameValue(['safe' => 1, 'unsafe' => 0], WpKitTestState::$httpCalls, 'HTTP facade transport changed'); + assertSameValue(true, $response->safe, 'HTTP facade response changed'); + assertSameValue('GET', WpKitTestState::$lastHttpRequest['options']['method'], 'HTTP facade method changed'); + } + + public function testRequestTypeAdminAndFrontendDetectionRemainComplementary(): void + { + WpKitTestState::$isAdmin = true; + assertSameValue(true, RequestType::is(RequestType::ADMIN), 'admin request detection changed'); + assertSameValue(false, RequestType::is(RequestType::FRONTEND), 'admin request was classified as frontend'); + + WpKitTestState::$isAdmin = false; + assertSameValue(true, RequestType::is(RequestType::FRONTEND), 'frontend request detection changed'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..aaa69d3 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,545 @@ +body = $body; + $this->query = $query; + $this->route = $route; + $this->json = $json; + } + + public function get_body_params() + { + return $this->body; + } + + public function get_json_params() + { + return $this->json; + } + + public function get_query_params() + { + return $this->query; + } + + public function get_url_params() + { + return $this->route; + } + } +} + +if (!class_exists('WP_REST_Response')) { + class WP_REST_Response + { + private $data; + + private $status; + + private $headers = []; + + public function set_data($data) + { + $this->data = $data; + } + + public function get_data() + { + return $this->data; + } + + public function set_status($status) + { + $this->status = $status; + } + + public function get_status() + { + return $this->status; + } + + public function set_headers($headers) + { + $this->headers = $headers; + } + + public function get_headers() + { + return $this->headers; + } + } +} + +if (!class_exists('WP_REST_Controller')) { + class WP_REST_Controller + { + } +} + +if (!class_exists('WP_REST_Server')) { + class WP_REST_Server + { + public const READABLE = 'GET'; + + public const CREATABLE = 'POST'; + + public const EDITABLE = 'POST, PUT, PATCH'; + + public const DELETABLE = 'DELETE'; + } +} + +function resetWpKitTestState() +{ + $scriptFilename = $_SERVER['SCRIPT_FILENAME'] ?? null; + + WpKitTestState::$httpCalls = [ + 'safe' => 0, + 'unsafe' => 0, + ]; + WpKitTestState::$lastHttpRequest = null; + WpKitTestState::$actions = []; + WpKitTestState::$filters = []; + WpKitTestState::$restRoutes = []; + WpKitTestState::$shortcodes = []; + WpKitTestState::$shortcodeRenders = []; + WpKitTestState::$rewriteRules = []; + WpKitTestState::$rewriteFlushes = 0; + WpKitTestState::$sentJson = null; + WpKitTestState::$options = [ + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i:s', + 'timezone_string' => 'UTC', + 'gmt_offset' => 0, + 'rewrite_rules' => [], + ]; + WpKitTestState::$currentTime = '2024-01-02 03:04:05'; + WpKitTestState::$capabilities = []; + WpKitTestState::$sites = []; + WpKitTestState::$switchedBlogs = []; + WpKitTestState::$restoredBlogs = 0; + WpKitTestState::$multisite = false; + WpKitTestState::$wpVersion = '6.6'; + WpKitTestState::$isAdmin = false; + + $_GET = []; + $_POST = []; + $_FILES = []; + $_REQUEST = []; + $_SERVER = [ + 'CONTENT_TYPE' => '', + 'REMOTE_ADDR' => '198.51.100.10', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/', + ]; + if ($scriptFilename !== null) { + $_SERVER['SCRIPT_FILENAME'] = $scriptFilename; + } + + if (class_exists(Response::class)) { + Response::reset(); + } + + if (class_exists(Router::class)) { + Router::reset(); + } + + if (class_exists(Request::class)) { + Request::setTrustedProxies([]); + } +} + +function assertTest($condition, $message) +{ + Assert::assertTrue((bool) $condition, $message); +} + +function assertSameValue($expected, $actual, $message) +{ + Assert::assertSame($expected, $actual, $message); +} + +function assertInstanceOf($class, $actual, $message) +{ + Assert::assertInstanceOf($class, $actual, $message); +} + +function assertThrows($exceptionClass, callable $callback, $message) +{ + try { + $callback(); + } catch (Throwable $exception) { + Assert::assertInstanceOf($exceptionClass, $exception, $message); + + return $exception; + } + + Assert::fail($message); +} + +function is_wp_error($value) +{ + return $value instanceof FakeWpError; +} + +// mirrors WordPress core: flushes every buffer level to output, returns nothing +function wp_ob_end_flush_all() +{ + $levels = ob_get_level(); + for ($i = 0; $i < $levels; ++$i) { + ob_end_flush(); + } +} + +function sanitize_text_field($value) +{ + return $value; +} + +function sanitize_url($value) +{ + return $value; +} + +function wp_unslash($value) +{ + return $value; +} + +function wp_parse_args($args, $defaults = []) +{ + return array_merge($defaults, (array) $args); +} + +function wp_json_encode($data, $options = 0, $depth = 512) +{ + return json_encode($data, $options, $depth); +} + +function wp_safe_remote_request($url, $options) +{ + ++WpKitTestState::$httpCalls['safe']; + WpKitTestState::$lastHttpRequest = compact('url', 'options'); + + if ($url === 'https://example.com/error') { + return new FakeWpError(); + } + + return [ + 'body' => '{"safe":true}', + 'headers' => ['X-Transport' => 'safe'], + 'response' => ['code' => 200], + ]; +} + +function wp_remote_request($url, $options) +{ + ++WpKitTestState::$httpCalls['unsafe']; + WpKitTestState::$lastHttpRequest = compact('url', 'options'); + + return [ + 'body' => '{"safe":false}', + 'headers' => ['X-Transport' => 'unsafe'], + 'response' => ['code' => 202], + ]; +} + +function wp_remote_retrieve_body($response) +{ + return $response['body']; +} + +function wp_remote_retrieve_headers($response) +{ + return $response['headers']; +} + +function wp_remote_retrieve_response_code($response) +{ + return $response['response']['code']; +} + +function wp_generate_password($length) +{ + return str_repeat('a', $length); +} + +function add_action($tag, $callback, $priority = 10, $acceptedArgs = 1) +{ + WpKitTestState::$actions[$tag][] = compact('callback', 'priority', 'acceptedArgs'); + + return true; +} + +function remove_action($tag, $callback, $priority = 10) +{ + if (!isset(WpKitTestState::$actions[$tag])) { + return false; + } + + foreach (WpKitTestState::$actions[$tag] as $index => $registered) { + if ($registered['callback'] === $callback && $registered['priority'] === $priority) { + unset(WpKitTestState::$actions[$tag][$index]); + + return true; + } + } + + return false; +} + +function do_action($tag, ...$args) +{ + foreach (WpKitTestState::$actions[$tag] ?? [] as $registered) { + call_user_func_array($registered['callback'], array_slice($args, 0, $registered['acceptedArgs'])); + } +} + +function add_filter($tag, $callback, $priority = 10, $acceptedArgs = 1) +{ + WpKitTestState::$filters[$tag][] = compact('callback', 'priority', 'acceptedArgs'); + + return true; +} + +function remove_filter($tag, $callback, $priority = 10) +{ + if (!isset(WpKitTestState::$filters[$tag])) { + return false; + } + + foreach (WpKitTestState::$filters[$tag] as $index => $registered) { + if ($registered['callback'] === $callback && $registered['priority'] === $priority) { + unset(WpKitTestState::$filters[$tag][$index]); + + return true; + } + } + + return false; +} + +function apply_filters($tag, $value, ...$args) +{ + foreach (WpKitTestState::$filters[$tag] ?? [] as $registered) { + $parameters = array_slice([$value, ...$args], 0, $registered['acceptedArgs']); + $value = call_user_func_array($registered['callback'], $parameters); + } + + return $value; +} + +function register_rest_route($namespace, $route, $args) +{ + WpKitTestState::$restRoutes[] = compact('namespace', 'route', 'args'); + + return true; +} + +function __return_true() +{ + return true; +} + +function wp_send_json($data, $status = null) +{ + WpKitTestState::$sentJson = compact('data', 'status'); + + return WpKitTestState::$sentJson; +} + +function add_rewrite_rule($regex, $query, $position) +{ + WpKitTestState::$rewriteRules[$regex] = compact('query', 'position'); +} + +function flush_rewrite_rules() +{ + ++WpKitTestState::$rewriteFlushes; +} + +function get_option($name) +{ + return WpKitTestState::$options[$name] ?? false; +} + +function current_time($type) +{ + return WpKitTestState::$currentTime; +} + +function wp_timezone_string() +{ + return WpKitTestState::$options['timezone_string']; +} + +function wp_timezone() +{ + return new DateTimeZone(wp_timezone_string()); +} + +function do_shortcode($content, $ignoreHtml = false) +{ + WpKitTestState::$shortcodeRenders[] = compact('content', 'ignoreHtml'); + + return 'rendered:' . $content . ($ignoreHtml ? ':ignore-html' : ''); +} + +function add_shortcode($tag, $callback) +{ + WpKitTestState::$shortcodes[$tag] = $callback; +} + +function remove_shortcode($tag) +{ + unset(WpKitTestState::$shortcodes[$tag]); +} + +function shortcode_exists($tag) +{ + return isset(WpKitTestState::$shortcodes[$tag]); +} + +function has_shortcode($content, $tag) +{ + return strpos($content, '[' . $tag) !== false; +} + +function current_user_can($capability, ...$args) +{ + return WpKitTestState::$capabilities[$capability] ?? false; +} + +function is_admin() +{ + return WpKitTestState::$isAdmin; +} + +function is_user_logged_in() +{ + return WpKitTestState::$capabilities['logged_in'] ?? false; +} + +function wp_get_current_user() +{ + return (object) ['ID' => 42]; +} + +function wp_kses($value, $allowedHtml) +{ + return strip_tags($value); +} + +function esc_html($value) +{ + return $value; +} + +function get_bloginfo($field) +{ + return $field === 'version' ? WpKitTestState::$wpVersion : null; +} + +function wp_die($message, $title = '') +{ + throw new WpDieException($title . ': ' . $message); +} + +function get_sites($args) +{ + return WpKitTestState::$sites; +} + +function get_current_network_id() +{ + return 1; +} + +function switch_to_blog($site) +{ + WpKitTestState::$switchedBlogs[] = $site; +} + +function restore_current_blog() +{ + ++WpKitTestState::$restoredBlogs; +} + +function is_multisite() +{ + return WpKitTestState::$multisite; +} diff --git a/tests/coverage.php b/tests/coverage.php new file mode 100644 index 0000000..0a64553 --- /dev/null +++ b/tests/coverage.php @@ -0,0 +1,116 @@ +run([ + 'phpunit', + '--configuration', + dirname(__DIR__) . '/phpunit.xml', + '--do-not-cache-result', +]); +$oplog = phpdbg_end_oplog(); + +$executable = phpdbg_get_executable(); +$targets = [ + BitApps\WPKit\Http\Router\RouteRegister::class => [ + 'handleMiddleware', + 'runMiddlewares', + 'handleRequest', + 'setRequest', + 'authorize', + 'validate', + 'handleAction', + 'invokeAsReflectionFunction', + 'processParameters', + 'invokeAsReflection', + 'block', + ], + BitApps\WPKit\Http\Router\Router::class => [ + 'setMiddlewares', + 'getRegisteredMiddleware', + ], + BitApps\WPKit\Http\Router\MiddlewareRegistry::class => [ + 'register', + 'resolve', + ], + BitApps\WPKit\Http\Request\Request::class => [ + 'ip', + 'setTrustedProxies', + ], + BitApps\WPKit\Http\Detection\ClientIpResolver::class => [ + 'setTrustedProxies', + 'checkIP', + 'normalizeIP', + 'isTrustedProxy', + 'isIpInRange', + ], + BitApps\WPKit\Http\Client\HttpClient::class => [ + 'allowUnsafeUrls', + 'request', + 'setDefault', + ], + BitApps\WPKit\Http\Response::class => [ + 'reset', + 'headers', + 'header', + ], +]; + +$coveredLineCount = 0; +$executableLineCount = 0; +$uncovered = []; + +foreach ($targets as $class => $methods) { + $reflection = new ReflectionClass($class); + foreach ($methods as $methodName) { + $method = $reflection->getMethod($methodName); + $file = realpath($method->getFileName()); + $methodExecutableLines = []; + + foreach (array_keys($executable[$file] ?? []) as $line) { + if ($line >= $method->getStartLine() && $line <= $method->getEndLine()) { + $methodExecutableLines[] = $line; + } + } + + $methodUncoveredLines = array_values( + array_filter($methodExecutableLines, static function ($line) use ($oplog, $file) { + return !isset($oplog[$file][$line]); + }), + ); + + $methodExecutableCount = count($methodExecutableLines); + $methodCoveredCount = $methodExecutableCount - count($methodUncoveredLines); + $executableLineCount += $methodExecutableCount; + $coveredLineCount += $methodCoveredCount; + + if (!empty($methodUncoveredLines)) { + $uncovered["{$class}::{$methodName}"] = $methodUncoveredLines; + } + } +} + +$coverage = $executableLineCount === 0 + ? 0 + : ($coveredLineCount / $executableLineCount) * 100; + +echo sprintf( + "\nSelected HTTP coverage: %.2f%% (%d/%d executable lines)\n", + $coverage, + $coveredLineCount, + $executableLineCount, +); + +foreach ($uncovered as $method => $lines) { + echo $method . ': uncovered lines ' . implode(', ', $lines) . "\n"; +} + +exit($testExitCode === 0 && $coveredLineCount === $executableLineCount ? 0 : 1); From 01d22eccce729dc94835a9fa51944fde7eba1380 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 29 Jul 2026 16:56:48 +0600 Subject: [PATCH 11/38] refactor: target PHP 8.0+, add type hints, swap lefthook for captainhook Bump platform floor to PHP 8.0 (composer require + phpcs compat testVersion 8.0-). Add param/return/property type declarations and 8.0 idioms (match, str_contains, constructor promotion, null-coalescing) across src via Rector run to a fixpoint. BC-conservative at extension points: public accessors on the extendable Request (and the IpTool trait) stay untyped so consumer subclass overrides keep compiling; Arr's public helper params stay untyped to preserve arg coercion. rector.php skips these files and a Request-subclass contract test pins the untyped-override guarantee. No strict_types, no enums/readonly (would break coercion / string constants / need 8.1). Fixes surfaced during typing: HttpClient::getBoundary() assigned $this to the boundary (object-to-string fatal on multipart); ShortcodeWrapper::doShortcode() and Request::input() discarded their return values; Request::files() could return null under an array contract. Tooling: remove the lefthook hook shim; add captainhook + hook-installer with a pre-commit hook running cs-fixer (check), compat, and tests. Rewrite README into an accurate quick-start. Assisted-By: AI --- .php-cs-fixer.php | 1 + README.md | 162 ++++++++++++++++- captainhook.json | 19 ++ composer.json | 16 +- rector.php | 15 ++ src/Configs/JsonConfig.php | 2 +- src/Helpers/Arr.php | 44 ++--- src/Helpers/DateTimeHelper.php | 172 +++++++----------- src/Helpers/JSON.php | 2 +- src/Helpers/Slug.php | 2 +- src/Hooks/Hooks.php | 8 +- src/Hooks/HooksWrapper.php | 2 +- src/Http/Client/Http.php | 8 +- src/Http/Client/HttpClient.php | 65 +++---- src/Http/Detection/ClientIpResolver.php | 14 +- src/Http/Detection/UserAgent.php | 6 +- src/Http/Request/Request.php | 36 ++-- src/Http/Response.php | 32 ++-- src/Http/Router/APIRouter.php | 34 ++-- src/Http/Router/AjaxRouter.php | 15 +- .../Router/Emitter/AjaxResponseEmitter.php | 2 +- .../Router/Emitter/ApiResponseEmitter.php | 2 +- src/Http/Router/MiddlewareRegistry.php | 4 +- src/Http/Router/ResponseEnvelope.php | 2 +- src/Http/Router/RewriteRuleSet.php | 12 +- src/Http/Router/Route.php | 4 +- src/Http/Router/RouteBase.php | 32 ++-- src/Http/Router/RouteBlockedException.php | 5 +- src/Http/Router/RoutePattern.php | 6 +- src/Http/Router/RouteRegister.php | 88 ++++----- src/Http/Router/Router.php | 57 +++--- src/Http/Router/StaticRouter.php | 18 +- src/Installer.php | 32 ++-- src/Migration/MigrationHelper.php | 6 +- src/Shortcode/Shortcode.php | 10 +- src/Shortcode/ShortcodeWrapper.php | 8 +- src/Utils/Capabilities.php | 2 +- tests/Http/RequestTest.php | 43 +++++ 38 files changed, 568 insertions(+), 420 deletions(-) create mode 100644 captainhook.json create mode 100644 rector.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index b5ef02f..a08f2a6 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -41,6 +41,7 @@ 'encoding' => true, 'ereg_to_preg' => true, 'explicit_indirect_variable' => true, + 'fully_qualified_strict_types' => true, 'explicit_string_variable' => true, 'function_declaration' => true, 'function_to_constant' => true, diff --git a/README.md b/README.md index 990e4ef..fd66b93 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,165 @@ -### WPKit +# WPKit ---- +A toolkit for building WordPress plugins: a routing layer over REST, AJAX and +static pages; form requests with validation and authorization; a fluent +response builder; a safe HTTP client; and thin facades over hooks, shortcodes, +migrations and the activation lifecycle. Public API is kept backward compatible. -# usage +## Install -1. Add this repository in composer.json +Add the repository to your `composer.json`: -``` +```json "repositories": [ + { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-kit" } +] +``` + +Then require the package: + +```bash +composer require bitapps/wp-kit:dev-main +``` + +## Quick start + +### 1. Wire a router (in your plugin bootstrap) + +```php +use BitApps\WPKit\Http\Router\Router; + +$api = new Router('api', 'myplugin', 'v1'); // REST namespace: myplugin/v1 +$api->setMiddlewares(['auth' => AuthMiddleware::class]); +$api->registerFile(__DIR__ . '/routes/api.php'); // define routes there +add_action('rest_api_init', [$api, 'register']); +``` + +Use `'ajax'` for admin-ajax routes and register those on `init`. Construct the +router before declaring its routes — a new router becomes the current one. + +### 2. Declare routes with the `Route` facade + +```php +// routes/api.php +use BitApps\WPKit\Http\Router\Route; + +Route::get('entries', [EntryController::class, 'index']); +Route::post('entries/{id}', [EntryController::class, 'update'])->middleware('auth'); + +Route::prefix('admin')->group(function () { + Route::get('stats', [StatsController::class, 'show'])->middleware('auth'); +}); +``` + +Path params (`{id}`, optional `{slug?}`) are injected by name into the action. + +### 3. Return responses + +```php +use BitApps\WPKit\Http\Response; + +class EntryController +{ + public function index() + { + return Response::success(['items' => []]); // 200 + } + + public function update($id) + { + return Response::error(['id' => $id], 404) // custom status + ->code('NOT_FOUND') + ->message('Entry not found'); + } +} +``` + +Returning a plain array/string wraps it in a success envelope automatically. + +### 4. Form requests — validation + authorization + +Type-hint a `Request` subclass on the action; wp-kit builds it, authorizes, +and validates before the action runs. Failures short-circuit with an error +response and the action never executes. + +```php +use BitApps\WPKit\Http\Request\Request; + +class EntryRequest extends Request +{ + public function authorize() { - "type": "vcs", - "url": "https://github.com/Bit-Apps-Pro/wp-kit" + return current_user_can('edit_posts'); } - ] + + public function rules() + { + return ['title' => ['required'], 'body' => ['required']]; + } +} + +// EntryController::store(EntryRequest $request) +$data = $request->all(); +$title = $request->input('title'); +$ip = $request->ip(); +``` + +### 5. Middleware + +Register aliases on the router, then apply them per route or group. A middleware +class defines `handle(Request $request, ...$params)` and returns `true` to pass +or a `Response` to block. Middleware fails closed — an unregistered alias, a +missing class, or one without `handle()` is rejected. + +```php +$router->setMiddlewares(['auth' => AuthMiddleware::class, 'role' => RoleMiddleware::class]); + +Route::post('entries', [EntryController::class, 'store'])->middleware('auth', 'role:editor'); ``` -2. Then install the package +### 6. HTTP client + +```php +use BitApps\WPKit\Http\Client\Http; +use BitApps\WPKit\Http\Client\HttpClient; +// Facade: returns the decoded JSON body (array) or raw string, or a WP_Error +$data = Http::post('https://api.example.com/hooks', ['event' => 'created']); + +// Instance: when you also need the status code / headers +$client = new HttpClient(); +$body = $client->request('https://api.example.com/hooks', 'POST', ['event' => 'created']); +$code = $client->getResponseCode(); ``` -composer require bitapps/wp-kit:dev-main + +Safe by default (`wp_safe_remote_request`); call `$client->allowUnsafeUrls()` to +reach internal hosts. + +### 7. Hooks, shortcodes, lifecycle + +```php +use BitApps\WPKit\Hooks\Hooks; +use BitApps\WPKit\Shortcode\Shortcode; + +Hooks::addAction('init', [$plugin, 'boot']); +Shortcode::addShortcode('myplugin_widget', [$plugin, 'renderWidget']); ``` +`Installer` handles activation/deactivation/uninstall and requirement checks; +`Migration` + `MigrationHelper` run schema migrations; `StaticRouter` maps custom +front-end page URLs (via rewrite rules) to routes. + +## Components + +- `Http\Router` — `Router`, `Route`/`RouteBase`, REST/AJAX transports, `StaticRouter`, `RequestType` +- `Http\Request\Request` — form requests, input access, IP resolution +- `Http\Response` — fluent response builder +- `Http\Client` — `HttpClient`, `Http` facade +- `Http\Detection` — `ClientIpResolver`, `UserAgent` +- `Hooks`, `Shortcode` — WordPress facades +- `Installer`, `Migration` — plugin lifecycle +- `Helpers` — `Arr`, `JSON`, `Slug`, `DateTimeHelper`; `Utils\Capabilities` + ## Security defaults - Route middleware fails closed. Every alias passed to `middleware()` must be @@ -74,6 +213,9 @@ composer require bitapps/wp-kit:dev-main invalid parameter names throw `InvalidArgumentException` at registration. Trade-off: an optional param no longer matches the empty-value-with-trailing- slash form (`entries/`) on AJAX routes — use `entries` (no slash) instead. +- IP/device detection classes moved to `Http\Detection\` (`ClientIpResolver`, + `UserAgent`). The `Http\IpTool` trait and `Request::ip()`/`device()` facade + are unchanged. ## Tests diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..316485a --- /dev/null +++ b/captainhook.json @@ -0,0 +1,19 @@ +{ + "config": { + "fail-on-first-error": true + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff" + }, + { + "action": "composer compat" + }, + { + "action": "composer test" + } + ] + } +} diff --git a/composer.json b/composer.json index 06d9b4f..bdc67e4 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "exclude": [ ".gitattributes", ".gitignore", - "lefthook.yml", + "captainhook.json", + "rector.php", ".php-cs-fixer.php", "composer.lock", ".vscode", @@ -21,6 +22,7 @@ ] }, "require": { + "php": ">=8.0", "bitapps/wp-validator": "^1.0" }, "require-dev": { @@ -28,7 +30,10 @@ "sirbrillig/phpcs-variable-analysis": "*", "dealerdirect/phpcodesniffer-composer-installer": "^0.7", "phpcompatibility/phpcompatibility-wp": "*", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.0", + "rector/rector": "^2.5", + "captainhook/captainhook": "^5.29", + "captainhook/hook-installer": "^1.0" }, "autoload": { "psr-4": { @@ -42,7 +47,9 @@ }, "scripts": { "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php", - "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 7.4-", + "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 8.0-", + "refactor": "./vendor/bin/rector process", + "rector": "./vendor/bin/rector process --dry-run", "test": "phpunit --configuration phpunit.xml", "coverage": "phpdbg -qrr tests/coverage.php" }, @@ -53,7 +60,8 @@ }, "config": { "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "captainhook/hook-installer": true } }, "minimum-stability": "stable", diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..809d1f4 --- /dev/null +++ b/rector.php @@ -0,0 +1,15 @@ +withPaths([__DIR__ . '/src']) + ->withPhpVersion(PhpVersion::PHP_80) + ->withPreparedSets(typeDeclarations: true) + ->withPhpSets(php80: true) + // BC-frozen extension points: consumers extend Request / use IpTool and call Arr + // with coerced args, so their public signatures must stay untyped (overrides + param coercion). + ->withSkipPath(__DIR__ . '/src/Http/Request/Request.php') + ->withSkipPath(__DIR__ . '/src/Http/IpTool.php') + ->withSkipPath(__DIR__ . '/src/Helpers/Arr.php'); diff --git a/src/Configs/JsonConfig.php b/src/Configs/JsonConfig.php index 4973309..34a37e0 100644 --- a/src/Configs/JsonConfig.php +++ b/src/Configs/JsonConfig.php @@ -6,7 +6,7 @@ final class JsonConfig { protected static $decodeAsArray = true; - public static function setDecodeAsArray($value) + public static function setDecodeAsArray($value): void { static::$decodeAsArray = $value; } diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php index 9ebd5cf..529c13a 100644 --- a/src/Helpers/Arr.php +++ b/src/Helpers/Arr.php @@ -23,7 +23,7 @@ class Arr * * @return bool */ - public static function accessible($value) + public static function accessible($value): bool { return \is_array($value) || $value instanceof ArrayAccess; } @@ -53,7 +53,7 @@ public static function add($array, $key, $value) * * @return array */ - public static function collapse($array) + public static function collapse($array): array { $results = []; @@ -75,7 +75,7 @@ public static function collapse($array) * * @return array */ - public static function crossJoin(...$arrays) + public static function crossJoin(...$arrays): array { $results = [[]]; @@ -103,7 +103,7 @@ public static function crossJoin(...$arrays) * * @return array */ - public static function divide($array) + public static function divide($array): array { return [array_keys($array), array_values($array)]; } @@ -116,7 +116,7 @@ public static function divide($array) * * @return array */ - public static function dot($array, $prepend = '') + public static function dot($array, $prepend = ''): array { $results = []; @@ -139,7 +139,7 @@ public static function dot($array, $prepend = '') * * @return array */ - public static function except($array, $keys) + public static function except($array, $keys): array { static::forget($array, $keys); @@ -154,7 +154,7 @@ public static function except($array, $keys) * * @return bool */ - public static function exists($array, $key) + public static function exists($array, $key): bool { if ($array instanceof ArrayAccess) { return $array->offsetExists($key); @@ -217,7 +217,7 @@ public static function last($array, ?callable $callback = null, $default = null) * * @return array */ - public static function flatten($array, $depth = INF) + public static function flatten($array, $depth = INF): array { $result = []; @@ -242,7 +242,7 @@ public static function flatten($array, $depth = INF) * @param array $array * @param array|string $keys */ - public static function forget(&$array, $keys) + public static function forget(&$array, $keys): void { $original = &$array; @@ -302,8 +302,8 @@ public static function get($array, $key, $default = null) return $array[$key]; } - if (strpos($key, '.') === false) { - return isset($array[$key]) ? $array[$key] : self::value($default); + if (!str_contains($key, '.')) { + return $array[$key] ?? self::value($default); } foreach (explode('.', $key) as $segment) { @@ -325,7 +325,7 @@ public static function get($array, $key, $default = null) * * @return bool */ - public static function has($array, $keys) + public static function has($array, $keys): bool { $keys = (array) $keys; @@ -360,7 +360,7 @@ public static function has($array, $keys) * * @return bool */ - public static function hasAny($array, $keys) + public static function hasAny($array, $keys): bool { if (\is_null($keys)) { return false; @@ -392,7 +392,7 @@ public static function hasAny($array, $keys) * * @return bool */ - public static function isAssoc(array $array) + public static function isAssoc(array $array): bool { $keys = array_keys($array); @@ -407,7 +407,7 @@ public static function isAssoc(array $array) * * @return array */ - public static function only($array, $keys) + public static function only($array, $keys): array { return array_intersect_key($array, array_flip((array) $keys)); } @@ -421,7 +421,7 @@ public static function only($array, $keys) * * @return array */ - public static function pluck($array, $value, $key = null) + public static function pluck($array, $value, $key = null): array { $results = []; @@ -496,7 +496,7 @@ public static function pull(&$array, $key, $default = null) * * @return string */ - public static function query($array) + public static function query($array): string { return http_build_query($array, '', '&', PHP_QUERY_RFC3986); } @@ -514,7 +514,7 @@ public static function query($array) */ public static function random($array, $number = null, $preserveKeys = false) { - $requested = \is_null($number) ? 1 : $number; + $requested = $number ?? 1; $count = \count($array); @@ -658,7 +658,7 @@ public static function sortRecursive($array, $options = SORT_REGULAR, $descendin * * @return string */ - public static function toCssClasses($array) + public static function toCssClasses($array): string { $classList = static::wrap($array); @@ -682,7 +682,7 @@ public static function toCssClasses($array) * * @return array */ - public static function where($array, callable $callback) + public static function where($array, callable $callback): array { return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH); } @@ -694,7 +694,7 @@ public static function where($array, callable $callback) * * @return array */ - public static function wrap($value) + public static function wrap($value): array { if (\is_null($value)) { return []; @@ -773,7 +773,7 @@ public static function dataGet($target, $key, $default = null) * * @return array */ - protected static function explodePluckParameters($value, $key) + protected static function explodePluckParameters($value, $key): array { $value = \is_string($value) ? explode('.', $value) : $value; diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php index d210634..fc8aa57 100644 --- a/src/Helpers/DateTimeHelper.php +++ b/src/Helpers/DateTimeHelper.php @@ -15,7 +15,7 @@ final class DateTimeHelper private $_currentTime; - private $_currentFormat; + private string $_currentFormat; public function __construct() { @@ -26,7 +26,7 @@ public function __construct() $this->_currentFormat = 'Y-m-d H:i:s'; } - public function getDate($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null) + public function getDate($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null): string|false { if (\is_null($date)) { $date = $this->_currentTime; @@ -34,15 +34,15 @@ public function getDate($date = null, $currentFormat = null, $currentTZ = null, $currentTZ = $this->_timezone; } - $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat; - $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ; - $expectedFormat = \is_null($expectedFormat) ? $this->_dateFormat : $expectedFormat; - $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ; + $currentFormat ??= $this->_currentFormat; + $currentTZ ??= $this->_timezone; + $expectedFormat ??= $this->_dateFormat; + $expectedTZ ??= $this->_timezone; return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ); } - public function getTime($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null) + public function getTime($date = null, $currentFormat = null, $currentTZ = null, $expectedFormat = null, $expectedTZ = null): string|false { if (\is_null($date)) { $date = $this->_currentTime; @@ -50,15 +50,15 @@ public function getTime($date = null, $currentFormat = null, $currentTZ = null, $currentTZ = $this->_timezone; } - $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat; - $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ; - $expectedFormat = \is_null($expectedFormat) ? $this->_timeFormat : $expectedFormat; - $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ; + $currentFormat ??= $this->_currentFormat; + $currentTZ ??= $this->_timezone; + $expectedFormat ??= $this->_timeFormat; + $expectedTZ ??= $this->_timezone; return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ); } - public function getDay($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null) + public function getDay($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null): string|false { if (\is_null($date)) { $date = $this->_currentTime; @@ -66,41 +66,22 @@ public function getDay($nameType, $date = null, $currentFormat = null, $currentT $currentTZ = $this->_timezone; } - $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat; - $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ; - $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ; + $currentFormat ??= $this->_currentFormat; + $currentTZ ??= $this->_timezone; + $expectedTZ ??= $this->_timezone; - switch ($nameType) { - case 'numeric-with-leading': - $expectedFormat = 'd'; - - break; - - case 'numeric-without-leading': - $expectedFormat = 'j'; - - break; - - case 'short-name': - $expectedFormat = 'D'; - - break; - - case 'full-name': - $expectedFormat = 'l'; - - break; - - default: - $expectedFormat = 'd'; - - break; - } + $expectedFormat = match ($nameType) { + 'numeric-with-leading' => 'd', + 'numeric-without-leading' => 'j', + 'short-name' => 'D', + 'full-name' => 'l', + default => 'd', + }; return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ); } - public function getMonth($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null) + public function getMonth($nameType, $date = null, $currentFormat = null, $currentTZ = null, $expectedTZ = null): string|false { if (\is_null($date)) { $date = $this->_currentTime; @@ -108,41 +89,22 @@ public function getMonth($nameType, $date = null, $currentFormat = null, $curren $currentTZ = $this->_timezone; } - $currentFormat = \is_null($currentFormat) ? $this->_currentFormat : $currentFormat; - $currentTZ = \is_null($currentTZ) ? $this->_timezone : $currentTZ; - $expectedTZ = \is_null($expectedTZ) ? $this->_timezone : $expectedTZ; + $currentFormat ??= $this->_currentFormat; + $currentTZ ??= $this->_timezone; + $expectedTZ ??= $this->_timezone; - switch ($nameType) { - case 'numeric-with-leading': - $expectedFormat = 'm'; - - break; - - case 'numeric-without-leading': - $expectedFormat = 'n'; - - break; - - case 'short-name': - $expectedFormat = 'M'; - - break; - - case 'full-name': - $expectedFormat = 'F'; - - break; - - default: - $expectedFormat = 'd'; - - break; - } + $expectedFormat = match ($nameType) { + 'numeric-with-leading' => 'm', + 'numeric-without-leading' => 'n', + 'short-name' => 'M', + 'full-name' => 'F', + default => 'd', + }; return $this->getFormated($date, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ); } - public function getFormated($dateString, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ) + public function getFormated($dateString, $currentFormat, $currentTZ, $expectedFormat, $expectedTZ): string|false { if ($currentFormat === false) { $dateObject = new DateTime($dateString, $currentTZ); @@ -167,17 +129,17 @@ public function getUnicodeLikeFormat($type, $format = null) switch ($type) { case 'date': - $format = \is_null($format) ? $this->_dateFormat : $format; + $format ??= $this->_dateFormat; break; case 'time': - $format = \is_null($format) ? $this->_timeFormat : $format; + $format ??= $this->_timeFormat; break; case 'timestamp': - $format = \is_null($format) ? $this->_currentFormat : $format; + $format ??= $this->_currentFormat; break; @@ -185,79 +147,79 @@ public function getUnicodeLikeFormat($type, $format = null) break; } - if (strpos($format, 'd') !== false) { + if (str_contains($format, 'd')) { $format = str_replace('d', 'dd', $format); } - if (strpos($format, 'j') !== false) { + if (str_contains($format, 'j')) { $format = str_replace('j', 'd', $format); } - if (strpos($format, 'D') !== false) { + if (str_contains($format, 'D')) { $format = str_replace('D', 'eee', $format); } - if (strpos($format, 'I') !== false) { + if (str_contains($format, 'I')) { $format = str_replace('I', 'eeee', $format); } - if (strpos($format, 'S') !== false) { + if (str_contains($format, 'S')) { $format = str_replace('S', 'F', $format); } - if (strpos($format, 'M') !== false) { + if (str_contains($format, 'M')) { $format = str_replace('M', 'MMM', $format); } - if (strpos($format, 'F') !== false) { + if (str_contains($format, 'F')) { $format = str_replace('F', 'MMMM', $format); } - if (strpos($format, 'm') !== false) { + if (str_contains($format, 'm')) { $format = str_replace('m', 'MM', $format); } - if (strpos($format, 'n') !== false) { + if (str_contains($format, 'n')) { $format = str_replace('n', 'M', $format); } - if (strpos($format, 'y') !== false) { + if (str_contains($format, 'y')) { $format = str_replace('y', 'yy', $format); } - if (strpos($format, 'Y') !== false) { + if (str_contains($format, 'Y')) { $format = str_replace('Y', 'yyyy', $format); } - if (strpos($format, 'a') !== false) { + if (str_contains($format, 'a')) { $format = str_replace('a', 'aaaa', $format); } - if (strpos($format, 'A') !== false) { + if (str_contains($format, 'A')) { $format = str_replace('A', 'aaaa', $format); } - if (strpos($format, 'g') !== false) { + if (str_contains($format, 'g')) { $format = str_replace('g', 'h', $format); } - if (strpos($format, 'G') !== false) { + if (str_contains($format, 'G')) { $format = str_replace('G', 'H', $format); } - if (strpos($format, 'h') !== false) { + if (str_contains($format, 'h')) { $format = str_replace('h', 'hh', $format); } - if (strpos($format, 'H') !== false) { + if (str_contains($format, 'H')) { $format = str_replace('H', 'HH', $format); } - if (strpos($format, 'i') !== false) { + if (str_contains($format, 'i')) { $format = str_replace('i', 'mm', $format); } - if (strpos($format, 's') !== false) { + if (str_contains($format, 's')) { $format = str_replace('s', 'ss', $format); } @@ -270,17 +232,17 @@ public function getUnicodeToPhpFormat($type, $format = null) switch ($type) { case 'date': - $format = \is_null($format) ? $this->_dateFormat : $format; + $format ??= $this->_dateFormat; break; case 'time': - $format = \is_null($format) ? $this->_timeFormat : $format; + $format ??= $this->_timeFormat; break; case 'timestamp': - $format = \is_null($format) ? $this->_currentFormat : $format; + $format ??= $this->_currentFormat; break; @@ -288,25 +250,25 @@ public function getUnicodeToPhpFormat($type, $format = null) break; } - if (strpos($format, 'd') !== false) { + if (str_contains($format, 'd')) { $format = str_replace('dd', 'd', $format); } - if (strpos($format, 'E') !== false) { + if (str_contains($format, 'E')) { $format = str_replace('E', 'D', $format); } - if (strpos($format, 'MMMM') !== false) { + if (str_contains($format, 'MMMM')) { $format = str_replace('MMMM', 'F', $format); - } elseif (strpos($format, 'MMM') !== false) { + } elseif (str_contains($format, 'MMM')) { $format = str_replace('MMM', 'M', $format); - } elseif (strpos($format, 'MM') !== false) { + } elseif (str_contains($format, 'MM')) { $format = str_replace('MM', 'm', $format); } - if (strpos($format, 'yyyy') !== false) { + if (str_contains($format, 'yyyy')) { $format = str_replace('yyyy', 'Y', $format); - } elseif (strpos($format, 'yy') !== false) { + } elseif (str_contains($format, 'yy')) { $format = str_replace('yy', 'y', $format); } @@ -345,7 +307,7 @@ public static function wp_timezone() return new DateTimeZone(self::wp_timezone_string()); } - public function getCurrentDateTime() + public function getCurrentDateTime(): string { $dateTime = new DateTime('now', self::wp_timezone()); diff --git a/src/Helpers/JSON.php b/src/Helpers/JSON.php index 29210e6..1eecde2 100644 --- a/src/Helpers/JSON.php +++ b/src/Helpers/JSON.php @@ -40,7 +40,7 @@ public static function maybeEncode($data, $options = 0, $depth = 512) * and NULL respectively. NULL is returned if the json cannot be decoded * or if the encoded data is deeper than the recursion limit. */ - public static function decode($json, $associative = false, $depth = 512, $flags = 0) + public static function decode($json, $associative = false, $depth = 512, $flags = 0): mixed { return json_decode($json, $associative, $depth, $flags); } diff --git a/src/Helpers/Slug.php b/src/Helpers/Slug.php index 5bf83a4..7a4040a 100644 --- a/src/Helpers/Slug.php +++ b/src/Helpers/Slug.php @@ -4,7 +4,7 @@ class Slug { - public static function generate($text) + public static function generate($text): string { $text = preg_replace('/[^a-zA-Z0-9]+/', '-', $text); diff --git a/src/Hooks/Hooks.php b/src/Hooks/Hooks.php index 9d6698d..02c269e 100644 --- a/src/Hooks/Hooks.php +++ b/src/Hooks/Hooks.php @@ -16,7 +16,7 @@ */ final class Hooks { - private static $_hook; + private static ?HooksWrapper $_hook = null; public function __construct() { @@ -25,7 +25,7 @@ public function __construct() } } - public function __call($method, $parameters) + public function __call(string $method, array $parameters) { if (method_exists($this->getInstance(), $method)) { return \call_user_func_array([$this->getInstance(), $method], $parameters); @@ -34,12 +34,12 @@ public function __call($method, $parameters) throw new RuntimeException('Undefined method [' . $method . '] called on Model class.'); } - public static function __callStatic($method, $parameters) + public static function __callStatic(string $method, array $parameters) { return (new static())->{$method}(...$parameters); } - public function getInstance() + public function getInstance(): ?HooksWrapper { return self::$_hook; } diff --git a/src/Hooks/HooksWrapper.php b/src/Hooks/HooksWrapper.php index 4ff04a9..ce8aca5 100644 --- a/src/Hooks/HooksWrapper.php +++ b/src/Hooks/HooksWrapper.php @@ -16,7 +16,7 @@ final class HooksWrapper * * @return void */ - public function doAction($tag, ...$arg) + public function doAction($tag, ...$arg): void { do_action($tag, ...$arg); } diff --git a/src/Http/Client/Http.php b/src/Http/Client/Http.php index 0bf3013..00d7cc9 100644 --- a/src/Http/Client/Http.php +++ b/src/Http/Client/Http.php @@ -9,17 +9,17 @@ class Http { private HttpClient $_client; - public function __call($method, $args) + public function __call(string $method, array $args) { return $this->forwardCall($method, $args); } - public static function __callStatic($method, $args) + public static function __callStatic(string $method, array $args) { return forward_static_call([new static(), 'forwardCall'], $method, $args); } - private function forwardCall($method, $args) + private function forwardCall(string $method, array $args) { if (!isset($this->_client)) { $this->_client = new HttpClient(); @@ -41,6 +41,6 @@ private function forwardCall($method, $args) return $this->_client->request($url, $method, ...$args); } - throw new BadMethodCallException(esc_html($method) . ' method not exists in ' . __CLASS__); + throw new BadMethodCallException(esc_html($method) . ' method not exists in ' . self::class); } } diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index ac3fa88..ab15cbe 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -9,7 +9,7 @@ final class HttpClient { - private $_headers = []; + private array $_headers = []; private $_body; @@ -25,29 +25,29 @@ final class HttpClient private $_baseUri; - private $_boundary; + private ?string $_boundary = null; - private $_method; + private ?string $_method = null; private $_responseHeaders = []; private $_requestResponse; - private $_options = []; + private array $_options = []; - private $_allowUnsafeUrls = false; + private bool $_allowUnsafeUrls = false; /** * Undocumented function. * * @param array $config */ - public function __construct($config = []) + public function __construct(array $config = []) { $this->setDefault($config); } - public function __call($method, $params) + public function __call(string $method, array $params) { if (\in_array($method, ['post', 'get', 'put','patch', 'delete', 'head', 'option'])) { $this->_method = $method; @@ -64,10 +64,10 @@ public function __call($method, $params) return $this->request($url, $method, $data, $headers, $options); } - throw new BadMethodCallException($method . ' Method not found in ' . __CLASS__); + throw new BadMethodCallException($method . ' Method not found in ' . self::class); } - public function setBaseUri($uri) + public function setBaseUri($uri): self { $this->_baseUri = $uri; @@ -79,7 +79,7 @@ public function getBaseUri() return $this->_baseUri; } - public function setHeaders(array $headers) + public function setHeaders(array $headers): self { if (empty($this->_headers)) { $this->_headers = $headers; @@ -92,7 +92,10 @@ public function setHeaders(array $headers) return $this; } - public function getHeaders() + /** + * @return mixed[] + */ + public function getHeaders(): array { $headers = []; foreach ($this->_headers as $key => $value) { @@ -104,7 +107,7 @@ public function getHeaders() public function getHeader($key) { - return isset($this->_headers[$key]) ? $this->_headers[$key] : false; + return $this->_headers[$key] ?? false; } public function setHeader($key, $value) @@ -112,42 +115,42 @@ public function setHeader($key, $value) return $this->_headers[ucwords($key)][] = $value; } - public function getOptions() + public function getOptions(): array { return $this->_options; } - public function setOptions(array $options) + public function setOptions(array $options): self { $this->_options = $options; return $this; } - public function allowUnsafeUrls($allow = true) + public function allowUnsafeUrls($allow = true): self { $this->_allowUnsafeUrls = (bool) $allow; return $this; } - public function setBoundary($boundary) + public function setBoundary($boundary): self { $this->_boundary = '-------' . (string) $boundary; return $this; } - public function getBoundary() + public function getBoundary(): string { if (!isset($this->_boundary)) { - $this->_boundary = $this->setBoundary(wp_generate_password(24)); + $this->setBoundary(wp_generate_password(24)); } return $this->_boundary; } - public function setContentType($contentType) + public function setContentType($contentType): self { $this->setHeader('Content-Type', $contentType); @@ -156,10 +159,10 @@ public function setContentType($contentType) public function getContentType($type) { - return isset($this->_headers[$type]) ? $this->_headers[$type] : ''; + return $this->_headers[$type] ?? ''; } - public function setParams($data) + public function setParams($data): self { $this->_params = $data; @@ -173,7 +176,7 @@ public function getParams() public function getParam($key) { - return isset($this->_params[$key]) ? $this->_params[$key] : false; + return $this->_params[$key] ?? false; } public function setParam($key, $value) @@ -181,7 +184,7 @@ public function setParam($key, $value) return $this->_params[$key] = $value; } - public function setQueryParams($data) + public function setQueryParams($data): self { $this->_queryParams = $data; @@ -195,10 +198,10 @@ public function getQueryParams() public function getQueryParam($key) { - return isset($this->_queryParams[$key]) ? $this->_queryParams[$key] : false; + return $this->_queryParams[$key] ?? false; } - public function setQueryParam($key, $value) + public function setQueryParam($key, $value): self { if (isset($this->_queryParams[$key])) { if (!\is_array($this->_queryParams[$key])) { @@ -213,7 +216,7 @@ public function setQueryParam($key, $value) return $this; } - public function setBody($body) + public function setBody($body): self { $this->_body = $body; @@ -265,7 +268,7 @@ public function getResponseCode() return wp_remote_retrieve_response_code($this->_requestResponse); } - public function setDefault(array $config) + public function setDefault(array $config): void { if (isset($config['base_uri'])) { $this->setBaseUri($config['base_uri']); @@ -300,7 +303,7 @@ public function setDefault(array $config) } } - public function setJson($data) + public function setJson($data): self { $this->setContentType('application/json'); $this->_json = $data; @@ -313,7 +316,7 @@ public function getJson() return $this->_json; } - public function setFormParams($data) + public function setFormParams($data): self { $this->setContentType('application/x-www-form-urlencoded'); $this->_formParams = $data; @@ -326,7 +329,7 @@ public function getFormParams() return $this->_formParams; } - public function setMultipart($data) + public function setMultipart($data): self { $this->setContentType('multipart/form-data; charset=UTF-8'); $this->_multipart = $data; @@ -366,7 +369,7 @@ public function getPreparedPayload() return $payload; } - public function getPreparedMultipart() + public function getPreparedMultipart(): string { $multipart = ''; if (!empty($this->getMultipart()) && \is_array($this->getMultipart())) { diff --git a/src/Http/Detection/ClientIpResolver.php b/src/Http/Detection/ClientIpResolver.php index 66c72ea..c589049 100644 --- a/src/Http/Detection/ClientIpResolver.php +++ b/src/Http/Detection/ClientIpResolver.php @@ -7,14 +7,14 @@ */ final class ClientIpResolver { - private static $trustedProxies = []; + private static array $trustedProxies = []; /** * Set proxy addresses or CIDR ranges that may supply X-Forwarded-For. * * @param array $proxies */ - public static function setTrustedProxies(array $proxies) + public static function setTrustedProxies(array $proxies): void { self::$trustedProxies = $proxies; } @@ -24,7 +24,7 @@ public static function setTrustedProxies(array $proxies) * * @return string IP address of current visitor */ - public static function checkIP() + public static function checkIP(): string|false { $remoteAddress = self::normalizeIP($_SERVER['REMOTE_ADDR'] ?? ''); if ($remoteAddress === false || !self::isTrustedProxy($remoteAddress)) { @@ -49,7 +49,7 @@ public static function checkIP() return $remoteAddress; } - private static function normalizeIP($ip) + private static function normalizeIP($ip): string|false { $ip = trim((string) $ip, " \t\n\r\0\x0B\""); @@ -62,7 +62,7 @@ private static function normalizeIP($ip) return filter_var($ip, FILTER_VALIDATE_IP); } - private static function isTrustedProxy($ip) + private static function isTrustedProxy(string $ip): bool { foreach (self::$trustedProxies as $trustedProxy) { if (self::isIpInRange($ip, $trustedProxy)) { @@ -73,10 +73,10 @@ private static function isTrustedProxy($ip) return false; } - private static function isIpInRange($ip, $range) + private static function isIpInRange(string $ip, $range) { $range = trim((string) $range); - if (strpos($range, '/') === false) { + if (!str_contains($range, '/')) { return $ip === self::normalizeIP($range); } diff --git a/src/Http/Detection/UserAgent.php b/src/Http/Detection/UserAgent.php index 0d414e2..e6ba1fb 100644 --- a/src/Http/Detection/UserAgent.php +++ b/src/Http/Detection/UserAgent.php @@ -10,7 +10,7 @@ final class UserAgent /** * Check device info. */ - public static function checkDevice() + public static function checkDevice(): string { return isset( $_SERVER['HTTP_USER_AGENT'] @@ -24,7 +24,7 @@ public static function checkDevice() * * @see https://stackoverflow.com/questions/18070154/get-operating-system-info */ - private static function getBrowserName($userAgent) + private static function getBrowserName($userAgent): string { // Make case insensitive. $t = strtolower($userAgent); @@ -151,7 +151,7 @@ private static function getBrowserName($userAgent) * * @see https://stackoverflow.com/questions/18070154/get-operating-system-info */ - private static function getOS($userAgent) + private static function getOS($userAgent): string { $ros[] = ['Windows XP', 'Windows XP']; $ros[] = ['Windows NT 5.1|Windows NT5.1', 'Windows XP']; diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php index 7c599a2..7fb16bc 100644 --- a/src/Http/Request/Request.php +++ b/src/Http/Request/Request.php @@ -18,11 +18,9 @@ class Request extends Validator implements ArrayAccess, JsonSerializable { use IpTool; - protected $route; - protected $rest; - protected $attributes = []; + protected array $attributes = []; protected $queryParams = []; @@ -33,45 +31,44 @@ class Request extends Validator implements ArrayAccess, JsonSerializable /** * Undocumented function. */ - public function __construct(?RouteRegister $route = null) + public function __construct(protected ?RouteRegister $route = null) { - $this->route = $route; $this->setBody(); $this->setQueryParams(); $this->setRouteParams(); $this->attributes = (array) $this->queryParams + (array) $this->body + (array) $this->routeParams; } - public function __isset($offset) + public function __isset(string $offset) { return $this->has($offset); } - public function __get($offset) + public function __get(string $offset) { return $this->get($offset); } - public function __set($offset, $value) + public function __set(string $offset, mixed $value) { $this->setAttribute($offset, $value); } - public function __unset($offset) + public function __unset(string $offset) { $this->unsetAttribute($offset); } - public function __call($method, $parameters) + public function __call(string $method, array $parameters) { if (isset($this->rest) && method_exists($this->rest, $method)) { return \call_user_func_array([$this->rest, $method], $parameters); } - throw new RuntimeException('Undefined method [' . (string) $method . '] called on ' . __CLASS__ . 'class.'); + throw new RuntimeException('Undefined method [' . (string) $method . '] called on ' . self::class . 'class.'); } - public static function __callStatic($method, $parameters) + public static function __callStatic(string $method, array $parameters) { return (new static())->{$method}(...$parameters); } @@ -123,13 +120,13 @@ public function setApiRequest(WP_REST_Request $request) } /** - * Provides all files in a request if exist otherwise returns null. + * Provides all files in a request, or an empty array when there are none. * - * @return null|array + * @return array */ public function files() { - return isset($_FILES) ? $_FILES : null; + return $_FILES ?? []; } public function all() @@ -144,9 +141,12 @@ public function get($offset, $default = null) public function input($offset, $default = null) { - $this->get($offset, $default); + return $this->get($offset, $default); } + /** + * @return mixed[] + */ public function except() { $paramToIgnore = \func_get_args(); @@ -243,8 +243,8 @@ protected function setBody($body = []) $this->body = $body; } else { if ( - strpos($this->contentType(), 'form-data') === false - && strpos($this->contentType(), 'x-www-form-urlencoded') === false + !str_contains($this->contentType(), 'form-data') + && !str_contains($this->contentType(), 'x-www-form-urlencoded') ) { $this->body = JSON::maybeDecode(file_get_contents('php://input'), JsonConfig::decodeAsArray()); } diff --git a/src/Http/Response.php b/src/Http/Response.php index 4f6fe38..8fbecc7 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -10,11 +10,11 @@ final class Response const ERROR = 'error'; - private static $_current; + private static ?Response $_current = null; private $_message; - private $_status; + private ?string $_status = null; private $_code; @@ -22,14 +22,14 @@ final class Response private $_httpStatus; - private $_headers = []; + private array $_headers = []; - public static function instance() + public static function instance(): Response { return self::current(); } - public static function reset() + public static function reset(): self { return self::$_current = new self(); } @@ -39,7 +39,7 @@ public static function reset() * * @return self */ - public static function adopt(self $response) + public static function adopt(self $response): self { return self::$_current = $response; } @@ -52,7 +52,7 @@ public static function adopt(self $response) * * @return self */ - public static function success($data, $httpStatus = 200) + public static function success($data, $httpStatus = 200): self { $current = self::current(); $current->_data = $data; @@ -71,7 +71,7 @@ public static function success($data, $httpStatus = 200) * * @return self */ - public static function error($data, $httpStatus = 400) + public static function error($data, $httpStatus = 400): self { $current = self::current(); $current->_data = $data; @@ -97,7 +97,7 @@ public static function getData() * * @return string $_status */ - public static function getStatus() + public static function getStatus(): ?string { return self::current()->_status; } @@ -109,7 +109,7 @@ public static function getStatus() * * @return self */ - public static function message($message) + public static function message($message): self { $current = self::current(); $current->_message = $message; @@ -134,7 +134,7 @@ public static function getMessage() * * @return self */ - public static function code($code) + public static function code($code): self { $current = self::current(); $current->_code = $code; @@ -164,7 +164,7 @@ public static function getCode() * * @return self */ - public static function httpStatus($code) + public static function httpStatus($code): self { $current = self::current(); $current->_httpStatus = $code; @@ -195,7 +195,7 @@ public static function getHttpStatusCode() * * @return self */ - public static function headers($headers) + public static function headers($headers): Response { if (!\is_array($headers)) { throw new InvalidArgumentException('Response headers must be an array.'); @@ -217,7 +217,7 @@ public static function headers($headers) * * @return self */ - public static function header($header, $value) + public static function header($header, $value): self { if (!\is_string($header) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $header) !== 1) { throw new InvalidArgumentException('Invalid response header name.'); @@ -238,12 +238,12 @@ public static function header($header, $value) * * @return array $_headers */ - public static function getHeaders() + public static function getHeaders(): array { return self::current()->_headers; } - private static function current() + private static function current(): Response { if (\is_null(self::$_current)) { self::$_current = new self(); diff --git a/src/Http/Router/APIRouter.php b/src/Http/Router/APIRouter.php index 1512d78..672d3ef 100644 --- a/src/Http/Router/APIRouter.php +++ b/src/Http/Router/APIRouter.php @@ -15,14 +15,11 @@ final class APIRouter extends WP_REST_Controller const DELETABLE = WP_REST_Server::DELETABLE; - private $_router; - - public function __construct(Router $router) + public function __construct(private Router $_router) { - $this->_router = $router; } - public function registerRoutes() + public function registerRoutes(): void { foreach ($this->_router->getRoutes() as $route) { $this->addRoute($route); @@ -34,7 +31,7 @@ public function registerRoutes() * * @param RouteRegister $route api route */ - public function addRoute(RouteRegister $route) + public function addRoute(RouteRegister $route): void { $args = []; foreach ($route->getMethods() as $method) { @@ -48,7 +45,7 @@ public function addRoute(RouteRegister $route) $path = $route->hasRegex() ? $route->regex() : $route->getPath(); $prefix = $route->getRoutePrefix(); if ($prefix) { - if (substr($prefix, -1) !== '/') { + if (!str_ends_with($prefix, '/')) { $path = $prefix . '/' . $path; } else { $path = $prefix . $path; @@ -63,21 +60,12 @@ public function addRoute(RouteRegister $route) public function getMethod($method) { - switch (strtolower($method)) { - case 'get': - return self::READABLE; - - case 'post': - return self::CREATABLE; - - case 'put': - return self::EDITABLE; - - case 'delete': - return self::DELETABLE; - - default: - return self::READABLE; - } + return match (strtolower($method)) { + 'get' => self::READABLE, + 'post' => self::CREATABLE, + 'put' => self::EDITABLE, + 'delete' => self::DELETABLE, + default => self::READABLE, + }; } } diff --git a/src/Http/Router/AjaxRouter.php b/src/Http/Router/AjaxRouter.php index d6c820a..c1d5984 100644 --- a/src/Http/Router/AjaxRouter.php +++ b/src/Http/Router/AjaxRouter.php @@ -11,26 +11,23 @@ */ final class AjaxRouter { - private $_router; - - public function __construct(Router $router) + public function __construct(private Router $_router) { - $this->_router = $router; } - public function registerRoutes() + public function registerRoutes(): void { foreach ($this->_router->getRoutes() as $route) { $this->addRoute($route); } } - public function addRoute(RouteRegister $route) + public function addRoute(RouteRegister $route): void { $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field($_SERVER['REQUEST_METHOD']) : ''; $action = isset($_REQUEST['action']) ? sanitize_text_field($_REQUEST['action']) : ''; - if (strpos($action, $route->getRouter()->getAjaxPrefix()) === false + if (!str_contains($action, $route->getRouter()->getAjaxPrefix()) || !\in_array(strtoupper($requestMethod), $route->getMethods()) ) { return; @@ -49,7 +46,7 @@ public function addRoute(RouteRegister $route) $route->getRouter()->addRegisteredRoute($this->currentRouteName(), $route); } - public function currentRouteName() + public function currentRouteName(): string { $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field($_SERVER['REQUEST_METHOD']) : ''; $action = isset($_REQUEST['action']) ? sanitize_text_field($_REQUEST['action']) : ''; @@ -67,7 +64,7 @@ public function currentRoute() return $this->_router->getRegisteredRoute($this->currentRouteName()); } - private function isRouteMatched(RouteRegister $route, $requestPath) + private function isRouteMatched(RouteRegister $route, string|array $requestPath) { if ($route->getRoutePrefix() . $route->getPath() === $requestPath) { return true; diff --git a/src/Http/Router/Emitter/AjaxResponseEmitter.php b/src/Http/Router/Emitter/AjaxResponseEmitter.php index 163a29b..8cf7a93 100644 --- a/src/Http/Router/Emitter/AjaxResponseEmitter.php +++ b/src/Http/Router/Emitter/AjaxResponseEmitter.php @@ -4,7 +4,7 @@ final class AjaxResponseEmitter implements ResponseEmitter { - public function emit(array $response) + public function emit(array $response): void { if (!headers_sent() && $response['headers']) { foreach ($response['headers'] as $key => $value) { diff --git a/src/Http/Router/Emitter/ApiResponseEmitter.php b/src/Http/Router/Emitter/ApiResponseEmitter.php index 85fd21e..baf3df4 100644 --- a/src/Http/Router/Emitter/ApiResponseEmitter.php +++ b/src/Http/Router/Emitter/ApiResponseEmitter.php @@ -6,7 +6,7 @@ final class ApiResponseEmitter implements ResponseEmitter { - public function emit(array $response) + public function emit(array $response): WP_REST_Response { $restResponse = new WP_REST_Response(); $restResponse->set_data($response['data']); diff --git a/src/Http/Router/MiddlewareRegistry.php b/src/Http/Router/MiddlewareRegistry.php index 2b7cf8f..cef4145 100644 --- a/src/Http/Router/MiddlewareRegistry.php +++ b/src/Http/Router/MiddlewareRegistry.php @@ -9,9 +9,9 @@ final class MiddlewareRegistry { private $_middlewares = []; - private $_resolved = []; + private array $_resolved = []; - public function register($middlewares) + public function register($middlewares): void { $this->_middlewares = $middlewares; $this->_resolved = []; diff --git a/src/Http/Router/ResponseEnvelope.php b/src/Http/Router/ResponseEnvelope.php index 4977113..7f542ea 100644 --- a/src/Http/Router/ResponseEnvelope.php +++ b/src/Http/Router/ResponseEnvelope.php @@ -15,7 +15,7 @@ final class ResponseEnvelope * * @return array ['data' => array, 'http_status' => int, 'headers' => array] */ - public static function build($result, $bufferedOutput = '') + public static function build($result, $bufferedOutput = ''): array { $response = self::normalize($result); diff --git a/src/Http/Router/RewriteRuleSet.php b/src/Http/Router/RewriteRuleSet.php index 2a6e0cc..e518106 100644 --- a/src/Http/Router/RewriteRuleSet.php +++ b/src/Http/Router/RewriteRuleSet.php @@ -7,18 +7,18 @@ */ final class RewriteRuleSet { - private $_pageName; + private string $_pageName; - private $_rules = []; + private array $_rules = []; - private $_queryVars = []; + private array $_queryVars = []; public function __construct(string $pageName) { $this->_pageName = trim($pageName, '/'); } - public function addPath(string $path) + public function addPath(string $path): void { if (empty($this->_rules)) { $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; @@ -46,12 +46,12 @@ public function addPath(string $path) } } - public function rules() + public function rules(): array { return $this->_rules; } - public function queryVars() + public function queryVars(): array { return array_values(array_unique($this->_queryVars)); } diff --git a/src/Http/Router/Route.php b/src/Http/Router/Route.php index 12e991d..22dce1b 100644 --- a/src/Http/Router/Route.php +++ b/src/Http/Router/Route.php @@ -49,7 +49,7 @@ final class Route * * @return RouteBase */ - public function __call($method, $parameters) + public function __call(string $method, array $parameters) { return \call_user_func_array([new RouteBase(), $method], $parameters); } @@ -62,7 +62,7 @@ public function __call($method, $parameters) * * @return RouteBase */ - public static function __callStatic($method, $parameters) + public static function __callStatic(string $method, array $parameters) { return \call_user_func_array([new RouteBase(), $method], $parameters); } diff --git a/src/Http/Router/RouteBase.php b/src/Http/Router/RouteBase.php index 12ed4f3..5cc0c88 100644 --- a/src/Http/Router/RouteBase.php +++ b/src/Http/Router/RouteBase.php @@ -39,13 +39,13 @@ final class RouteBase private $_prefix; - private $_noAuth; + private ?bool $_noAuth = null; - private $_ignoreToken; + private ?bool $_ignoreToken = null; - private $_middleware = []; + private array $_middleware = []; - private static $_isGrouped; + private static ?self $_isGrouped = null; /** * Handle static call to route. @@ -55,7 +55,7 @@ final class RouteBase * * @return RouteRegister */ - public function __call($method, $parameters) + public function __call(string $method, array $parameters) { if (method_exists(RouteRegister::class, $method)) { $route = \call_user_func_array([$this->getRegistrar(), $method], $parameters); @@ -64,10 +64,10 @@ public function __call($method, $parameters) return $route; } - throw new RuntimeException('Undefined method [' . $method . '] called on ' . __CLASS__ . ' class.'); + throw new RuntimeException('Undefined method [' . $method . '] called on ' . self::class . ' class.'); } - public static function __callStatic($method, $parameters) + public static function __callStatic(string $method, array $parameters) { return (new static())->{$method}(...$parameters); } @@ -79,7 +79,7 @@ public static function __callStatic($method, $parameters) * * @return RouteBase */ - public function prefix($prefix) + public function prefix($prefix): self { $this->_prefix = $prefix; @@ -91,7 +91,7 @@ public function prefix($prefix) * * @return RouteBase */ - public function noAuth() + public function noAuth(): self { $this->_noAuth = true; @@ -103,7 +103,7 @@ public function noAuth() * * @return bool */ - public function isNoAuth() + public function isNoAuth(): ?bool { return $this->_noAuth; } @@ -113,7 +113,7 @@ public function isNoAuth() * * @return bool */ - public function isTokenIgnored() + public function isTokenIgnored(): ?bool { return $this->_ignoreToken; } @@ -123,7 +123,7 @@ public function isTokenIgnored() * * @return RouteBase */ - public function ignoreToken() + public function ignoreToken(): self { $this->_ignoreToken = true; @@ -135,7 +135,7 @@ public function ignoreToken() * * @return RouteBase */ - public function middleware() + public function middleware(): self { $this->_middleware = array_merge($this->_middleware, \func_get_args()); @@ -147,7 +147,7 @@ public function middleware() * * @return [] */ - public function getMiddleware() + public function getMiddleware(): array { return $this->_middleware; } @@ -169,7 +169,7 @@ public function getRoutePrefix() * * @return $this */ - public function group(Closure $callback) + public function group(Closure $callback): self { self::$_isGrouped = $this; $callback(); @@ -197,7 +197,7 @@ public function getRouter() * * @return RouteRegister */ - private function getRegistrar() + private function getRegistrar(): RouteRegister { $instance = $this; if (!\is_null(self::$_isGrouped)) { diff --git a/src/Http/Router/RouteBlockedException.php b/src/Http/Router/RouteBlockedException.php index 86aa14a..419627e 100644 --- a/src/Http/Router/RouteBlockedException.php +++ b/src/Http/Router/RouteBlockedException.php @@ -9,12 +9,9 @@ */ final class RouteBlockedException extends RuntimeException { - private $_response; - - public function __construct($response) + public function __construct(private $_response) { parent::__construct('Route dispatch blocked'); - $this->_response = $response; } public function getResponse() diff --git a/src/Http/Router/RoutePattern.php b/src/Http/Router/RoutePattern.php index 4bc8683..38104b6 100644 --- a/src/Http/Router/RoutePattern.php +++ b/src/Http/Router/RoutePattern.php @@ -33,12 +33,12 @@ public static function compile(string $path) throw new InvalidArgumentException("Duplicate route parameter [{$name}] in path [{$path}]."); } - $required = strpos($placeholder, '?') === false; + $required = !str_contains($placeholder, '?'); $params[$name] = ['required' => $required]; $literal = substr($path, $cursor, $offset - $cursor); $cursor = $offset + \strlen($placeholder); - if (!$required && substr($literal, -1) === '/') { + if (!$required && str_ends_with($literal, '/')) { // fold the separator into the optional group so "entries" matches "entries/{slug?}" $regex .= self::quoteLiteral(substr($literal, 0, -1)) . "(?:\\/(?P<{$name}>[^\\/]+))?"; @@ -51,7 +51,7 @@ public static function compile(string $path) return ['regex' => $regex . self::quoteLiteral(substr($path, $cursor)), 'params' => $params]; } - private static function quoteLiteral($literal) + private static function quoteLiteral(string $literal): string { return str_replace('/', '\/', preg_quote($literal, '~')); } diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php index 577f292..8883515 100644 --- a/src/Http/Router/RouteRegister.php +++ b/src/Http/Router/RouteRegister.php @@ -17,19 +17,17 @@ final class RouteRegister { private $_name; - private $_methods = []; + private array $_methods = []; private $_action; private $_path; - private $_routeBase; - private $_routeParams = []; private $_routeParamValues = []; - private $_middleware = []; + private array $_middleware = []; /** * Instance of rest request. @@ -45,20 +43,19 @@ final class RouteRegister */ private $_request; - private $_response = []; + private array $_response = []; - private $_bufferLevel; + private ?int $_bufferLevel = null; private $_compiled; - private $_compiledDone = false; + private bool $_compiledDone = false; - public function __construct(RouteBase $routeBase) + public function __construct(private RouteBase $_routeBase) { - $this->_routeBase = $routeBase; } - public function match($methods, $path, $action) + public function match($methods, $path, $action): self { if (\is_string($methods)) { $methods = explode(',', $methods); @@ -71,22 +68,22 @@ public function match($methods, $path, $action) return $this; } - public function get($path, $action) + public function get($path, $action): RouteRegister { return $this->register('GET', $path, $action); } - public function post($path, $action) + public function post($path, $action): RouteRegister { return $this->register('POST', $path, $action); } - public function getMethods() + public function getMethods(): array { return $this->_methods; } - public function action($action) + public function action($action): self { $this->_action = $action; @@ -98,7 +95,7 @@ public function getAction() return $this->_action; } - public function path($path) + public function path($path): self { $this->_path = $path; $this->_compiledDone = false; @@ -111,7 +108,7 @@ public function getPath() return $this->_path; } - public function name($name) + public function name($name): self { $this->_name = $name; @@ -142,24 +139,24 @@ public function regex() return $this->makeRegex(); } - public function hasRegex() + public function hasRegex(): bool { return $this->compiledPattern() !== null; } - public function getMiddleware() + public function getMiddleware(): array { return array_merge($this->_routeBase->getMiddleware(), $this->_middleware); } - public function middleware() + public function middleware(): self { $this->_middleware = array_merge($this->_middleware, \func_get_args()); return $this; } - public function handleMiddleware() + public function handleMiddleware(): bool { try { $this->runMiddlewares(); @@ -191,7 +188,7 @@ public function getRouteParams() return $this->_routeParams; } - public function setRouteParamValue($name, $value) + public function setRouteParamValue($name, $value): void { $this->_routeParamValues[$name] = $value; } @@ -325,7 +322,7 @@ private function resolveParamValue(ReflectionParameter $param) return $value; } - private function runMiddlewares() + private function runMiddlewares(): void { if (empty($middlewares = $this->getMiddleware())) { return; @@ -342,7 +339,7 @@ private function runMiddlewares() try { $middlewareObj = $router->getRegisteredMiddleware($middleware); - } catch (MiddlewareConfigurationException $exception) { + } catch (MiddlewareConfigurationException) { throw new RouteBlockedException(Response::error([], 500)->code('MIDDLEWARE_CONFIGURATION')->message('Route middleware is not configured')); } @@ -353,7 +350,7 @@ private function runMiddlewares() } } - private function setRestRequest(WP_REST_Request $request) + private function setRestRequest(WP_REST_Request $request): void { $this->_restRequest = $request; } @@ -390,7 +387,7 @@ private function setRequest($request = null) return $this->_request; } - private function authorize() + private function authorize(): void { if (method_exists($this->_request, 'authorize') && !$this->_request->authorize()) { $message = 'You are not authorized to access this endpoint'; @@ -406,7 +403,7 @@ private function authorize() } } - private function validate() + private function validate(): void { if (method_exists($this->_request, 'rules')) { $messages = []; @@ -433,7 +430,7 @@ private function validate() } } - private function register($method, $path, $action) + private function register($method, $path, $action): self { $this->_methods[] = strtoupper($method); $this->path($path); @@ -467,12 +464,12 @@ private function compiledPattern() return $this->_compiled; } - private function setRouteParam($name, $attribute) + private function setRouteParam($name, $attribute): void { $this->_routeParams[$name] = $attribute; } - private function handleAction() + private function handleAction(): void { $action = $this->getAction(); if (\is_array($action) && method_exists($action[0], $action[1])) { @@ -489,7 +486,7 @@ private function handleAction() /** * @param Closure|string $method */ - private function invokeAsReflectionFunction($method) + private function invokeAsReflectionFunction(callable $method): mixed { $reflectionFunction = new ReflectionFunction($method); $params = $this->processParameters($reflectionFunction->getParameters()); @@ -497,7 +494,7 @@ private function invokeAsReflectionFunction($method) return $reflectionFunction->invoke(...$params); } - private function processParameters($reflectionParams, $params = []) + private function processParameters($reflectionParams, array $params = []): array { $requestParams = []; foreach ($reflectionParams as $param) { @@ -507,7 +504,7 @@ private function processParameters($reflectionParams, $params = []) return array_merge($requestParams, $params); } - private function invokeAsReflection($class, $method, $params = []) + private function invokeAsReflection($class, $method, array $params = []): mixed { $reflectionMethod = new ReflectionMethod($class, $method); $reflectionParams = $reflectionMethod->getParameters(); @@ -526,12 +523,12 @@ private function invokeAsReflection($class, $method, $params = []) return $reflectionMethod->invoke($reflectionMethod->isStatic() ? null : new $class(), ...$params); } - private function block($response) + private function block($response): void { throw new RouteBlockedException($response); } - private function recordBlock(RouteBlockedException $exception) + private function recordBlock(RouteBlockedException $exception): void { $this->setResponse($exception->getResponse()); } @@ -539,7 +536,7 @@ private function recordBlock(RouteBlockedException $exception) /** * Captures stray output from the buffer handleRequest() opened; never touches buffers owned by others. */ - private function collectBufferedOutput() + private function collectBufferedOutput(): string|false { if ($this->_bufferLevel === null || ob_get_level() <= $this->_bufferLevel) { return ''; @@ -550,7 +547,7 @@ private function collectBufferedOutput() return ob_get_clean(); } - private function setResponse($response) + private function setResponse($response): void { $this->_response = ResponseEnvelope::build($response, $this->collectBufferedOutput()); } @@ -560,18 +557,13 @@ private function sendResponse() return $this->resolveEmitter()->emit($this->_response); } - private function resolveEmitter() + private function resolveEmitter(): Emitter\ResponseEmitter { - switch ($this->getRouterType()) { - case RequestType::API: - return new Emitter\ApiResponseEmitter(); - - case RequestType::AJAX: - return new Emitter\AjaxResponseEmitter(); - - default: - // static/web plus any custom type: hand the raw action output back to the caller - return new Emitter\StaticResponseEmitter(); - } + return match ($this->getRouterType()) { + RequestType::API => new Emitter\ApiResponseEmitter(), + RequestType::AJAX => new Emitter\AjaxResponseEmitter(), + // static/web plus any custom type: hand the raw action output back to the caller + default => new Emitter\StaticResponseEmitter(), + }; } } diff --git a/src/Http/Router/Router.php b/src/Http/Router/Router.php index 89fc2d5..5c3418e 100644 --- a/src/Http/Router/Router.php +++ b/src/Http/Router/Router.php @@ -6,31 +6,22 @@ final class Router { - private $_routes = []; + private array $_routes = []; - private $_registeredRoutes = []; + private array $_registeredRoutes = []; - private $_middlewareRegistry; + private MiddlewareRegistry $_middlewareRegistry; - private $_namespace; - - private $_version; - - private $_requestType; - - private static $_instance; + private static ?self $_instance = null; // keyed by type only — two routers of the same type share one slot, last constructed wins - private static $_registry = []; + private static array $_registry = []; - public function __construct($type, $namespace, $version) + public function __construct(private $_requestType, private $_namespace, private $_version) { - $this->_namespace = $namespace; - $this->_version = $version; - $this->_requestType = $type; - $this->_middlewareRegistry = new MiddlewareRegistry(); - self::$_instance = $this; - self::$_registry[$type] = $this; + $this->_middlewareRegistry = new MiddlewareRegistry(); + self::$_instance = $this; + self::$_registry[$this->_requestType] = $this; } public function getRequestType() @@ -38,7 +29,7 @@ public function getRequestType() return $this->_requestType; } - public function getVersion() + public function getVersion(): string { return empty($this->_version) ? '' : $this->_version . '/'; } @@ -48,37 +39,37 @@ public function getNamespace() return $this->_namespace; } - public function getAjaxPrefix() + public function getAjaxPrefix(): string { return $this->getNamespace() . (empty($this->_version) ? '' : '/' . $this->_version); } - public function getRoutes() + public function getRoutes(): array { return $this->_routes; } public function getRoute($routeIndex) { - return isset($this->_routes[$routeIndex]) ? $this->_routes[$routeIndex] : null; + return $this->_routes[$routeIndex] ?? null; } - public function addRoute(RouteRegister $route) + public function addRoute(RouteRegister $route): void { $this->_routes[] = $route; } - public function addRegisteredRoute($name, RouteRegister $route) + public function addRegisteredRoute($name, RouteRegister $route): void { $this->_registeredRoutes[$name] = $route; } public function getRegisteredRoute($routeName) { - return isset($this->_registeredRoutes[$routeName]) ? $this->_registeredRoutes[$routeName] : null; + return $this->_registeredRoutes[$routeName] ?? null; } - public function getRegisteredRoutes() + public function getRegisteredRoutes(): array { return $this->_registeredRoutes; } @@ -93,28 +84,24 @@ public static function instance($type = null, $namespace = null, $version = null return self::$_instance; } - if (isset(self::$_registry[$type])) { - return self::$_registry[$type]; - } - // creates, registers, AND makes the new router current — declare routes before constructing transports - return new self($type, $namespace, $version); + return self::$_registry[$type] ?? new self($type, $namespace, $version); } - public static function reset() + public static function reset(): void { self::$_instance = null; self::$_registry = []; } - public function registerFile($routeFile) + public function registerFile($routeFile): void { self::$_instance = $this; include_once $routeFile; } - public function register() + public function register(): void { if ($this->getRequestType() === RequestType::AJAX) { $ajaxRouter = new AjaxRouter($this); @@ -125,7 +112,7 @@ public function register() } } - public function setMiddlewares($middlewares) + public function setMiddlewares($middlewares): void { $this->_middlewareRegistry->register($middlewares); } diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index c84aaee..9c65836 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -27,18 +27,18 @@ public function __construct(string $pageName, string $activationHook, string $de $this->registerHooks($activationHook, $deactivationHook); } - public function flushOnActivate() + public function flushOnActivate(): void { $this->registerRewriteRules(); flush_rewrite_rules(); } - public function flushOnDeactivate() + public function flushOnDeactivate(): void { flush_rewrite_rules(); } - public function registerRewriteRules() + public function registerRewriteRules(): void { $this->processRoutes(); @@ -53,12 +53,12 @@ public function registerRewriteRules() $this->maybeFlushRewriteRules(); } - public function addQueryVars($vars) + public function addQueryVars($vars): array { return array_merge($vars, $this->queryVars); } - public function handleRequest() + public function handleRequest(): void { $requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); foreach ($this->router->getRoutes() as $route) { @@ -78,7 +78,7 @@ public function renderContent(string $content): string return $content . ($this->content ?? ''); } - public function loadRoutesFromFile($filePath) + public function loadRoutesFromFile($filePath): void { $this->router->registerFile($filePath); } @@ -109,7 +109,7 @@ public static function isRewriteExists(?string $path = '', ?array $rewriteRules return false; } - public function maybeFlushRewriteRules() + public function maybeFlushRewriteRules(): void { if (empty($this->rewriteRules) || self::isRewriteExists('', $this->rewriteRules)) { return; @@ -118,7 +118,7 @@ public function maybeFlushRewriteRules() flush_rewrite_rules(); } - private function registerHooks(string $activationHook, string $deactivationHook) + private function registerHooks(string $activationHook, string $deactivationHook): void { add_action($activationHook, [$this, 'flushOnActivate']); add_action($deactivationHook, [$this, 'flushOnDeactivate']); @@ -127,7 +127,7 @@ private function registerHooks(string $activationHook, string $deactivationHook) add_action('template_redirect', [$this, 'handleRequest']); } - private function processRoutes() + private function processRoutes(): void { $ruleSet = new RewriteRuleSet($this->pageName); foreach ($this->router->getRoutes() as $route) { diff --git a/src/Installer.php b/src/Installer.php index 42a7198..aa75a74 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -16,10 +16,6 @@ */ final class Installer { - private $_requirements; - - private $_hooks; - private $_migration; private static $_drop; @@ -27,19 +23,17 @@ final class Installer /** * Sets necessary elements * - * @param array $requirements - * @param array $hooks + * @param array $_requirements + * @param array $_hooks * @param array $migration */ - public function __construct($requirements, $hooks, $migration) + public function __construct(private $_requirements, private $_hooks, array $migration) { - $this->_requirements = $requirements; - $this->_hooks = $hooks; - $this->_migration = $migration['migration']; - self::$_drop = $migration['drop']; + $this->_migration = $migration['migration']; + self::$_drop = $migration['drop']; } - public function register() + public function register(): void { if (isset($this->_hooks['activate'])) { Hooks::addAction($this->_hooks['activate'], [$this, 'activate']); @@ -51,7 +45,7 @@ public function register() } } - public function activate($isNetworkActivation) + public function activate($isNetworkActivation): void { $this->checkRequirements(); if ( @@ -64,14 +58,14 @@ public function activate($isNetworkActivation) } } - public function activateOnSingleSite() + public function activateOnSingleSite(): void { if (version_compare($this->_requirements['oldVersion'], $this->_requirements['version'], '<')) { MigrationHelper::migrate($this->_migration); } } - public function activateOnMultiSite() + public function activateOnMultiSite(): void { $sites = get_sites((['fields' => 'ids', 'network_id' => get_current_network_id()])); foreach ($sites as $site) { @@ -81,7 +75,7 @@ public function activateOnMultiSite() } } - public static function uninstall() + public static function uninstall(): void { if (is_multisite()) { self::uninstallFromAllSite(); @@ -90,12 +84,12 @@ public static function uninstall() } } - public static function uninstallFromSingleSite() + public static function uninstallFromSingleSite(): void { MigrationHelper::drop(self::$_drop); } - public static function uninstallFromAllSite() + public static function uninstallFromAllSite(): void { $sites = get_sites((['fields' => 'ids', 'network_id' => get_current_network_id()])); @@ -106,7 +100,7 @@ public static function uninstallFromAllSite() } } - public function checkRequirements() + public function checkRequirements(): void { if (version_compare(PHP_VERSION, $this->_requirements['php'], '<')) { // Str From WP install script diff --git a/src/Migration/MigrationHelper.php b/src/Migration/MigrationHelper.php index 97ccaa8..7b553da 100644 --- a/src/Migration/MigrationHelper.php +++ b/src/Migration/MigrationHelper.php @@ -26,7 +26,7 @@ final class MigrationHelper * * @return void */ - public static function migrate($migrations) + public static function migrate(array $migrations): void { $instance = self::getMigrationInstances($migrations); @@ -44,7 +44,7 @@ public static function migrate($migrations) * 'path' base path of migrations * 'migrations' Array of Migration class */ - public static function drop($migrations) + public static function drop(array $migrations): void { $instance = self::getMigrationInstances($migrations); @@ -62,7 +62,7 @@ public static function drop($migrations) * * @return array */ - public static function getMigrationInstances($migrations) + public static function getMigrationInstances(array $migrations): array { $basePath = $migrations['path']; $migrationClassNames = $migrations['migrations']; diff --git a/src/Shortcode/Shortcode.php b/src/Shortcode/Shortcode.php index 83e86e7..83b780b 100644 --- a/src/Shortcode/Shortcode.php +++ b/src/Shortcode/Shortcode.php @@ -15,7 +15,7 @@ */ final class Shortcode { - private static $_wrapper; + private static ?ShortcodeWrapper $_wrapper = null; public function __construct() { @@ -24,21 +24,21 @@ public function __construct() } } - public function __call($method, $parameters) + public function __call(string $method, array $parameters) { if (method_exists($this->getInstance(), $method)) { return \call_user_func_array([$this->getInstance(), $method], $parameters); } - throw new RuntimeException('Undefined method [' . $method . '] called on ' . __CLASS__ . ' class.'); + throw new RuntimeException('Undefined method [' . $method . '] called on ' . self::class . ' class.'); } - public static function __callStatic($method, $parameters) + public static function __callStatic(string $method, array $parameters) { return (new static())->{$method}(...$parameters); } - public function getInstance() + public function getInstance(): ?ShortcodeWrapper { return self::$_wrapper; } diff --git a/src/Shortcode/ShortcodeWrapper.php b/src/Shortcode/ShortcodeWrapper.php index 5bfc549..ca011c0 100644 --- a/src/Shortcode/ShortcodeWrapper.php +++ b/src/Shortcode/ShortcodeWrapper.php @@ -16,9 +16,9 @@ final class ShortcodeWrapper * * @return string Content with shortcodes filtered out. */ - public function doShortcode($content, $ignoreHtml = false) + public function doShortcode($content, $ignoreHtml = false): string { - do_shortcode($content, $ignoreHtml); + return do_shortcode($content, $ignoreHtml); } /** @@ -33,7 +33,7 @@ public function doShortcode($content, $ignoreHtml = false) * * @return void */ - public function addShortcode($tag, $callback) + public function addShortcode($tag, $callback): void { add_shortcode($tag, $callback); } @@ -45,7 +45,7 @@ public function addShortcode($tag, $callback) * * @return void */ - public function removeShortcode($tag) + public function removeShortcode($tag): void { remove_shortcode($tag); } diff --git a/src/Utils/Capabilities.php b/src/Utils/Capabilities.php index 4676603..933992c 100644 --- a/src/Utils/Capabilities.php +++ b/src/Utils/Capabilities.php @@ -11,7 +11,7 @@ public static function check($cap, ...$args) return current_user_can($cap, ...$args); } - public static function filter($cap, $default = 'manage_options') + public static function filter($cap, $default = 'manage_options'): bool { return static::check($cap) || static::check(Hooks::applyFilter($cap, $default)); } diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 4150bb6..ed48cd3 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -7,6 +7,41 @@ use WP_REST_Request; use WpKitTestState; +/** + * Pins the BC contract: a consumer Request subclass may override accessors with + * UNTYPED signatures. If a parent accessor regains a return type, this class fails + * to load ("Declaration must be compatible") and the suite errors — the guard the + * rest of the suite misses because its other Request subclasses only override + * Validator hooks. + */ +final class ContractOverridingRequest extends Request +{ + public function all() + { + return ['overridden' => true]; + } + + public function has($offset) + { + return true; + } + + public function except() + { + return []; + } + + public function files() + { + return []; + } + + public function getRoute() + { + return null; + } +} + /** * @internal * @@ -14,6 +49,14 @@ */ final class RequestTest extends TestCase { + public function testConsumerSubclassMayOverrideAccessorsWithUntypedSignatures(): void + { + $request = new ContractOverridingRequest(); + + assertSameValue(['overridden' => true], $request->all(), 'untyped all() override was not honored'); + assertTest($request->has('anything'), 'untyped has() override was not honored'); + } + public function testRequestQueryBodyAndFilesRemainSeparatelyObservable(): void { $_GET = ['query' => 'value', 'shared' => 'query']; From 906ed71d6a5fe3d8643c7708a4bc27d6c227cb5b Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 09:19:32 +0600 Subject: [PATCH 12/38] feat: configure unsafe HTTP host allowlist --- src/Http/Client/HttpClient.php | 61 +++++++++++++++++++++++++++++++--- tests/Http/HttpClientTest.php | 50 +++++++++++++++++++++++----- tests/bootstrap.php | 2 ++ 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index ab15cbe..2ac296d 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -37,6 +37,8 @@ final class HttpClient private bool $_allowUnsafeUrls = false; + private array $_allowedUnsafeHosts = []; + /** * Undocumented function. * @@ -127,9 +129,25 @@ public function setOptions(array $options): self return $this; } - public function allowUnsafeUrls($allow = true): self + public function allowUnsafeUrls($allow = true, array $allowedHosts = []): self { $this->_allowUnsafeUrls = (bool) $allow; + $this->setAllowedUnsafeHosts($allowedHosts); + + return $this; + } + + public function getAllowedUnsafeHosts(): array + { + return $this->_allowedUnsafeHosts; + } + + public function setAllowedUnsafeHosts(array $hosts): self + { + $this->_allowedUnsafeHosts = array_values(array_unique(array_filter(array_map( + [$this, 'normalizeHost'], + $hosts, + )))); return $this; } @@ -298,9 +316,10 @@ public function setDefault(array $config): void $this->setMultipart($config['multipart']); } - if (isset($config['allow_unsafe_urls'])) { - $this->allowUnsafeUrls($config['allow_unsafe_urls']); - } + $this->allowUnsafeUrls( + $config['allow_unsafe_urls'] ?? false, + $config['allowed_unsafe_hosts'] ?? [], + ); } public function setJson($data): self @@ -406,4 +425,38 @@ public function getPreparedMultipart(): string return $multipart; } + + private function normalizeHost($host): string + { + if (!\is_string($host)) { + return ''; + } + + $host = trim($host); + if ($host === '') { + return ''; + } + + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return ''; + } + } + + if ($host === '' || filter_var($host, FILTER_VALIDATE_IP) !== false) { + return strtolower($host); + } + + $host = strtolower($host); + if (str_ends_with($host, '.')) { + $host = substr($host, 0, -1); + } + + if ($host === '' || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) { + return ''; + } + + return $host; + } } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index e644929..77293c9 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -34,6 +34,38 @@ public function testHTTPClientUnsafeRemoteRequestsRequireExplicitOptIn(): void assertSameValue(false, $response->safe, 'unsafe transport response was not returned'); } + public function testHTTPClientUnsafeUrlAllowlistIsNormalizedAndReplaced(): void + { + $client = new HttpClient(); + + assertSameValue( + $client, + $client->allowUnsafeUrls(true, [' INTERNAL.example. ', '[::1]', '127.0.0.1', 'internal.example']), + 'unsafe URL configuration stopped being fluent', + ); + assertSameValue( + ['internal.example', '::1', '127.0.0.1'], + $client->getAllowedUnsafeHosts(), + 'unsafe host allowlist was not normalized', + ); + + $client->allowUnsafeUrls(true); + + assertSameValue([], $client->getAllowedUnsafeHosts(), 'empty unsafe host allowlist did not replace prior entries'); + } + + public function testHTTPClientUnsafeHostAllowlistRejectsInvalidValues(): void + { + $client = new HttpClient(); + + assertSameValue( + $client, + $client->setAllowedUnsafeHosts(['', ' https://internal.example ', 'host/path', '[bracketed.example]', [], true, 123, 'valid.example']), + 'unsafe host allowlist setter stopped being fluent', + ); + assertSameValue(['valid.example'], $client->getAllowedUnsafeHosts(), 'invalid unsafe host entries were retained'); + } + public function testHTTPClientWordPressErrorsAreReturnedUnchanged(): void { $client = new HttpClient(); @@ -45,14 +77,15 @@ public function testHTTPClientWordPressErrorsAreReturnedUnchanged(): void public function testHTTPClientConstructorAppliesSupportedDefaults(): void { $client = new HttpClient([ - 'base_uri' => 'https://example.com/', - 'content_type' => 'text/plain', - 'headers' => ['X-Test' => 'value'], - 'body' => ['body' => 'value'], - 'form_params' => ['form' => 'value'], - 'json' => ['json' => 'value'], - 'multipart' => [['name' => 'part', 'contents' => 'value']], - 'allow_unsafe_urls' => true, + 'base_uri' => 'https://example.com/', + 'content_type' => 'text/plain', + 'headers' => ['X-Test' => 'value'], + 'body' => ['body' => 'value'], + 'form_params' => ['form' => 'value'], + 'json' => ['json' => 'value'], + 'multipart' => [['name' => 'part', 'contents' => 'value']], + 'allow_unsafe_urls' => true, + 'allowed_unsafe_hosts' => ['internal.example'], ]); assertSameValue('https://example.com/', $client->getBaseUri(), 'base URI default was not applied'); @@ -65,6 +98,7 @@ public function testHTTPClientConstructorAppliesSupportedDefaults(): void $client->getMultipart(), 'multipart default was not applied', ); + assertSameValue(['internal.example'], $client->getAllowedUnsafeHosts(), 'unsafe host allowlist default was not applied'); } public function testHTTPClientFluentConfigurationRetainsRequestValues(): void diff --git a/tests/bootstrap.php b/tests/bootstrap.php index aaa69d3..3203822 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,6 +13,8 @@ define('ABSPATH', __DIR__ . '/Fixtures/wordpress/'); } +require_once dirname(__DIR__) . '/src/Http/Client/HttpClient.php'; + final class WpKitTestState { public static $httpCalls = []; From f2b28b7fba14c195ff7ad41b1b184850457ca7f0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 09:27:19 +0600 Subject: [PATCH 13/38] fix: restrict unsafe HTTP requests by host --- src/Http/Client/HttpClient.php | 31 +++++++- tests/Http/HttpClientTest.php | 135 +++++++++++++++++++++++++++++++-- tests/bootstrap.php | 27 ++++++- 3 files changed, 182 insertions(+), 11 deletions(-) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 2ac296d..97e7853 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -6,6 +6,7 @@ use BitApps\WPKit\Helpers\JSON; use InvalidArgumentException; +use WP_Error; final class HttpClient { @@ -257,9 +258,18 @@ public function request($url, $type, $data, $headers = null, $options = null) ]; $options = wp_parse_args($options, $defaultOptions); - $requestResponse = $this->_allowUnsafeUrls - ? wp_remote_request($url, $options) - : wp_safe_remote_request($url, $options); + if ($this->_allowUnsafeUrls) { + if (!$this->isUnsafeUrlAllowed($url)) { + $this->_requestResponse = new WP_Error('unsafe_url_not_allowed', 'Unsafe URL host is not allowlisted.'); + + return $this->_requestResponse; + } + + $options['redirection'] = 0; + $requestResponse = wp_remote_request($url, $options); + } else { + $requestResponse = wp_safe_remote_request($url, $options); + } $this->_requestResponse = $requestResponse; @@ -459,4 +469,19 @@ private function normalizeHost($host): string return $host; } + + private function isUnsafeUrlAllowed($url): bool + { + $urlParts = wp_parse_url($url); + if (!\is_array($urlParts) || !isset($urlParts['scheme'], $urlParts['host'])) { + return false; + } + + $scheme = strtolower($urlParts['scheme']); + $host = $this->normalizeHost($urlParts['host']); + + return \in_array($scheme, ['http', 'https'], true) + && $host !== '' + && \in_array($host, $this->_allowedUnsafeHosts, true); + } } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 77293c9..6819a46 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -6,6 +6,7 @@ use BitApps\WPKit\Http\Client\HttpClient; use BitApps\WPKit\Tests\TestCase; use FakeWpError; +use WP_Error; use WpKitTestState; /** @@ -24,23 +25,137 @@ public function testHTTPClientSafeRemoteRequestsAreTheDefault(): void assertSameValue(true, $response->safe, 'JSON response was not decoded'); } - public function testHTTPClientUnsafeRemoteRequestsRequireExplicitOptIn(): void + public function testHTTPClientUnsafeRemoteRequestsFailClosedWithoutAnAllowlist(): void { - $client = new HttpClient(); - $client->allowUnsafeUrls(); + $client = (new HttpClient())->allowUnsafeUrls(); $response = $client->request('http://internal.example', 'GET', []); - assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'unsafe opt-in was ignored'); + assertInstanceOf(WP_Error::class, $response, 'unsafe request without an allowlist was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'rejected URL reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsPermitAnExactAllowlistedHost(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('http://internal.example/resource', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'exact host did not use unsafe transport'); assertSameValue(false, $response->safe, 'unsafe transport response was not returned'); } + public function testHTTPClientUnsafeRemoteRequestsRejectADifferentHost(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('http://other.example/resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'different host was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'different host reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsRejectSubdomainsOfAnAllowlistedHost(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['example.com']); + $response = $client->request('http://evil.example.com/resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'subdomain was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'subdomain reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsRejectHostsWithAnAllowlistedPrefix(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['example.com']); + $response = $client->request('http://example.com.evil.test/resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'host with allowlisted prefix was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'prefixed host reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsAuthorizeTheParsedHostNotUserInfo(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['example.com']); + $response = $client->request('http://user@example.com/resource', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'URL user info changed host authorization'); + assertSameValue(false, $response->safe, 'parsed host was not authorized'); + } + + public function testHTTPClientUnsafeRemoteRequestsNormalizeTheParsedHost(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['EXAMPLE.COM']); + $response = $client->request('http://example.com./resource', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'normalized host did not use unsafe transport'); + assertSameValue(false, $response->safe, 'normalized host was not authorized'); + } + + public function testHTTPClientUnsafeRemoteRequestsRejectUnsupportedSchemes(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('ftp://internal.example/resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'unsupported scheme was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'unsupported scheme reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsRejectHostlessUrls(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('/resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'hostless URL was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'hostless URL reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsRejectMalformedUrls(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('http:///resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'malformed URL was not rejected'); + assertSameValue('unsafe_url_not_allowed', $response->get_error_code(), 'unexpected unsafe URL error code'); + assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'malformed URL reached a transport'); + } + + public function testHTTPClientUnsafeRemoteRequestsPermitAnExactIpv6Host(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['2001:db8::1']); + $response = $client->request('http://[2001:db8::1]/resource', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'exact IPv6 host did not use unsafe transport'); + assertSameValue(false, $response->safe, 'IPv6 host was not authorized'); + } + + public function testHTTPClientUnsafeRemoteRequestsAuthorizeHostsIndependentOfPort(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('https://internal.example:8443/resource', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'URL port changed host authorization'); + assertSameValue(false, $response->safe, 'host with a different port was not authorized'); + } + + public function testHTTPClientUnsafeRemoteRequestsDisableRedirects(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $client->request('http://internal.example/resource', 'GET', null, null, ['redirection' => 5]); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'allowlisted host did not use unsafe transport'); + assertSameValue(0, WpKitTestState::$lastHttpRequest['options']['redirection'], 'unsafe transport retained redirects'); + } + public function testHTTPClientUnsafeUrlAllowlistIsNormalizedAndReplaced(): void { $client = new HttpClient(); assertSameValue( $client, - $client->allowUnsafeUrls(true, [' INTERNAL.example. ', '[::1]', '127.0.0.1', 'internal.example']), + $client->allowUnsafeUrls(true, [' INTERNAL.example. ', '[::1]', '127.0.0.1']), 'unsafe URL configuration stopped being fluent', ); assertSameValue( @@ -60,12 +175,20 @@ public function testHTTPClientUnsafeHostAllowlistRejectsInvalidValues(): void assertSameValue( $client, - $client->setAllowedUnsafeHosts(['', ' https://internal.example ', 'host/path', '[bracketed.example]', [], true, 123, 'valid.example']), + $client->setAllowedUnsafeHosts(['', ' ', ' https://internal.example ', 'host/path', '[bracketed.example]', [], true, 123, 'valid.example']), 'unsafe host allowlist setter stopped being fluent', ); assertSameValue(['valid.example'], $client->getAllowedUnsafeHosts(), 'invalid unsafe host entries were retained'); } + public function testHTTPClientUnsafeHostAllowlistStoresNormalizedDuplicatesOnce(): void + { + $client = new HttpClient(); + $client->setAllowedUnsafeHosts(['EXAMPLE.COM', 'example.com.', ' example.com ']); + + assertSameValue(['example.com'], $client->getAllowedUnsafeHosts(), 'normalized duplicate hosts were retained'); + } + public function testHTTPClientWordPressErrorsAreReturnedUnchanged(): void { $client = new HttpClient(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3203822..e50bc4e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -56,7 +56,25 @@ final class WpKitTestState public static $isAdmin = false; } -final class FakeWpError +class WP_Error +{ + private $code; + + private $message; + + public function __construct($code = '', $message = '') + { + $this->code = $code; + $this->message = $message; + } + + public function get_error_code() + { + return $this->code; + } +} + +final class FakeWpError extends WP_Error { } @@ -255,7 +273,7 @@ function assertThrows($exceptionClass, callable $callback, $message) function is_wp_error($value) { - return $value instanceof FakeWpError; + return $value instanceof WP_Error; } // mirrors WordPress core: flushes every buffer level to output, returns nothing @@ -287,6 +305,11 @@ function wp_parse_args($args, $defaults = []) return array_merge($defaults, (array) $args); } +function wp_parse_url($url, $component = -1) +{ + return parse_url($url, $component); +} + function wp_json_encode($data, $options = 0, $depth = 512) { return json_encode($data, $options, $depth); From 8e8ca276bbc9a7387a05a008ec1a61498d0f4b5f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 09:34:45 +0600 Subject: [PATCH 14/38] docs: explain private HTTP host authorization --- README.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fd66b93..7e5c8d0 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,17 @@ $body = $client->request('https://api.example.com/hooks', 'POST', ['event' => $code = $client->getResponseCode(); ``` -Safe by default (`wp_safe_remote_request`); call `$client->allowUnsafeUrls()` to -reach internal hosts. +Safe by default (`wp_safe_remote_request`). To reach known internal hosts, an +administrator must explicitly enable unsafe URLs and allowlist each exact trusted +endpoint host: + +```php +$client = (new HttpClient()) + ->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']); +``` + +Only administrator-configured trusted endpoints belong in this allowlist; never +derive hosts from arbitrary request values. Unsafe requests never follow redirects. ### 7. Hooks, shortcodes, lifecycle @@ -177,14 +186,22 @@ front-end page URLs (via rewrite rules) to routes. ``` - `HttpClient` uses `wp_safe_remote_request()` by default. Internal or otherwise - unsafe URLs require an explicit opt-in: + unsafe URLs require an explicit, administrator-configured exact-host allowlist: ```php - $client->allowUnsafeUrls(); + $client->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']); ``` + Do not allowlist hosts supplied by arbitrary requests. Unsafe requests do not + follow redirects. + ## Upgrade notes +- `HttpClient::allowUnsafeUrls()` remains a valid call, but calling it without an + explicit host allowlist now intentionally fails closed. Unsafe requests return + a `WP_Error` with the `unsafe_url_not_allowed` code instead of reaching the + transport. Configure exact, trusted hosts with + `allowUnsafeUrls(true, ['internal-api.example'])`. - `Response::headers()` now requires an array and validates every entry through `Response::header()`; invalid header names, values containing CR/LF/NUL, and non-scalar values throw `InvalidArgumentException` instead of being stored From a4599cb3658ca49902216f009ff175641c48316c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 09:50:14 +0600 Subject: [PATCH 15/38] fix: reset HTTP response state on rejection --- README.md | 4 ++++ src/Http/Client/HttpClient.php | 2 ++ tests/Http/HttpClientTest.php | 27 +++++++++++++++++++++++++++ tests/bootstrap.php | 4 ++++ 4 files changed, 37 insertions(+) diff --git a/README.md b/README.md index 7e5c8d0..fcb64bc 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,10 @@ $client = (new HttpClient()) ->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']); ``` +Authorization is host-only: URLs must use HTTP or HTTPS, but any port and path +on an allowlisted host remain reachable. Validate untrusted URL components +separately. + Only administrator-configured trusted endpoints belong in this allowlist; never derive hosts from arbitrary request values. Unsafe requests never follow redirects. diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 97e7853..ed26340 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -249,6 +249,8 @@ public function getBody() public function request($url, $type, $data, $headers = null, $options = null) { + $this->_responseHeaders = []; + $defaultOptions = [ 'method' => strtoupper($type), 'headers' => empty($headers) ? $this->getHeaders() : $headers, diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 6819a46..de949e1 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -35,6 +35,28 @@ public function testHTTPClientUnsafeRemoteRequestsFailClosedWithoutAnAllowlist() assertSameValue(['safe' => 0, 'unsafe' => 0], WpKitTestState::$httpCalls, 'rejected URL reached a transport'); } + public function testHTTPClientAuthorizationRejectionClearsHeadersFromAPriorResponse(): void + { + $client = new HttpClient(); + $client->request('https://example.com', 'GET', []); + + assertSameValue(['X-Transport' => 'safe'], $client->getResponseHeaders(), 'successful response headers were not stored'); + + $client->allowUnsafeUrls(true, ['internal.example']); + $response = $client->request('http://other.example/resource', 'GET', []); + + assertInstanceOf(WP_Error::class, $response, 'non-allowlisted host was not rejected'); + assertSameValue([], $client->getResponseHeaders(), 'authorization rejection retained stale response headers'); + } + + public function testHTTPClientResponseCodeIsEmptyAfterAuthorizationRejection(): void + { + $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); + $client->request('http://other.example/resource', 'GET', []); + + assertSameValue('', $client->getResponseCode(), 'authorization rejection exposed a stale or malformed response code'); + } + public function testHTTPClientUnsafeRemoteRequestsPermitAnExactAllowlistedHost(): void { $client = (new HttpClient())->allowUnsafeUrls(true, ['internal.example']); @@ -222,6 +244,11 @@ public function testHTTPClientConstructorAppliesSupportedDefaults(): void 'multipart default was not applied', ); assertSameValue(['internal.example'], $client->getAllowedUnsafeHosts(), 'unsafe host allowlist default was not applied'); + + $response = $client->request('http://internal.example/resource', 'GET', []); + + assertSameValue(['safe' => 0, 'unsafe' => 1], WpKitTestState::$httpCalls, 'constructor did not enable unsafe transport'); + assertSameValue(false, $response->safe, 'constructor-configured unsafe transport response was not returned'); } public function testHTTPClientFluentConfigurationRetainsRequestValues(): void diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e50bc4e..9d761e9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -355,6 +355,10 @@ function wp_remote_retrieve_headers($response) function wp_remote_retrieve_response_code($response) { + if (is_wp_error($response) || !isset($response['response']) || !is_array($response['response'])) { + return ''; + } + return $response['response']['code']; } From 3a221dc687121e02eb944a742e34aefc60b39b89 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:21:13 +0600 Subject: [PATCH 16/38] build: align test tooling with PHP 8 --- .github/workflows/tests.yml | 25 +++++++++++++++++++++++++ composer.json | 9 +++++++-- phpunit.xml | 11 ++++++++--- src/Http/Router/ResponseEnvelope.php | 2 +- 4 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1f2a477 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: tests + +on: + pull_request: + push: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.0', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: composer validate --no-check-publish --no-interaction + - run: composer lint + - run: composer compat + - run: composer rector + - run: composer test diff --git a/composer.json b/composer.json index bdc67e4..f3473f7 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "sirbrillig/phpcs-variable-analysis": "*", "dealerdirect/phpcodesniffer-composer-installer": "^0.7", "phpcompatibility/phpcompatibility-wp": "*", - "phpunit/phpunit": "^13.0", + "phpunit/phpunit": "^9.6", "rector/rector": "^2.5", "captainhook/captainhook": "^5.29", "captainhook/hook-installer": "^1.0" @@ -46,7 +46,8 @@ } }, "scripts": { - "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php", + "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff", + "lint:fix": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php", "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 8.0-", "refactor": "./vendor/bin/rector process", "rector": "./vendor/bin/rector process --dry-run", @@ -59,8 +60,12 @@ } }, "config": { + "platform": { + "php": "8.0.0" + }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, + "captainhook/captainhook": true, "captainhook/hook-installer": true } }, diff --git a/phpunit.xml b/phpunit.xml index 15fb8a6..7d22a07 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,18 @@ - + tests - + src - + diff --git a/src/Http/Router/ResponseEnvelope.php b/src/Http/Router/ResponseEnvelope.php index 7f542ea..2e61363 100644 --- a/src/Http/Router/ResponseEnvelope.php +++ b/src/Http/Router/ResponseEnvelope.php @@ -44,7 +44,7 @@ public static function build($result, $bufferedOutput = ''): array ]; } - private static function normalize($result) + private static function normalize($result): Response { if (is_wp_error($result)) { return Response::error($result->get_error_data()) From 636eccc99418c4c169bace8cb97f70c4a554e419 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:23:48 +0600 Subject: [PATCH 17/38] fix(router): compile complete static rewrites --- src/Http/Router/RewriteRuleSet.php | 43 ++++++++++++++---------- src/Http/Router/RoutePattern.php | 52 +++++++++++++++++++++-------- src/Http/Router/StaticRouter.php | 6 ++-- tests/Router/RewriteRuleSetTest.php | 29 ++++++++++++++-- tests/Router/RoutePatternTest.php | 12 +++++++ tests/Router/StaticRoutingTest.php | 24 +++++++++---- 6 files changed, 123 insertions(+), 43 deletions(-) diff --git a/src/Http/Router/RewriteRuleSet.php b/src/Http/Router/RewriteRuleSet.php index e518106..ab8eff8 100644 --- a/src/Http/Router/RewriteRuleSet.php +++ b/src/Http/Router/RewriteRuleSet.php @@ -24,26 +24,33 @@ public function addPath(string $path): void $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; } - preg_match_all(RoutePattern::PLACEHOLDER, $path, $regexMatched); - $path = $this->_pageName . '/' . $path . '/'; - $matchCount = 1; - $previousPath = "^{$this->_pageName}/?$"; - - foreach ($regexMatched[0] as $param) { - $param = trim($param, '{}?'); - $pathChunk = substr($path, 0, strpos($path, "{{$param}}")); - $pathChunkWithoutParam = '^' . $pathChunk . '?$'; - $pathChunkWithParam = '^' . $pathChunk . '([^/]+)/?$'; - - $path = str_replace("{{$param}}", '([^/]+)', $path); - if (!isset($this->_rules[$pathChunkWithoutParam]) && strpos($pathChunkWithoutParam, '([^/]+)')) { - $previousPath = trim(substr($pathChunkWithoutParam, 0, strpos($pathChunkWithoutParam, '([^/]+)') + \strlen('([^/]+)') + 1), '/') . '/?$'; + $path = trim($path, '/'); + if ($path === '') { + return; + } + + $regex = '^' . preg_quote($this->_pageName, '~') . '/'; + $query = 'index.php?pagename=' . $this->_pageName; + $cursor = 0; + $matchIndex = 1; + + foreach (RoutePattern::placeholders($path) as $placeholder) { + $literal = substr($path, $cursor, $placeholder['offset'] - $cursor); + $cursor = $placeholder['offset'] + \strlen($placeholder['token']); + + if (!$placeholder['required'] && str_ends_with($literal, '/')) { + $regex .= preg_quote(substr($literal, 0, -1), '~') . '(?:/([^/]+))?'; + } else { + $regex .= preg_quote($literal, '~') . '([^/]+)' . ($placeholder['required'] ? '' : '?'); } - $this->_rules[$pathChunkWithoutParam] = $this->_rules[$previousPath]; - $this->_rules[$pathChunkWithParam] = $this->_rules[$pathChunkWithoutParam] . "&{$param}=\$matches[{$matchCount}]"; - ++$matchCount; - $this->_queryVars[] = $param; + + $query .= '&' . $placeholder['name'] . '=$matches[' . $matchIndex . ']'; + $this->_queryVars[] = $placeholder['name']; + ++$matchIndex; } + + $regex .= preg_quote(substr($path, $cursor), '~') . '/?$'; + $this->_rules[$regex] = $query; } public function rules(): array diff --git a/src/Http/Router/RoutePattern.php b/src/Http/Router/RoutePattern.php index 38104b6..81e3958 100644 --- a/src/Http/Router/RoutePattern.php +++ b/src/Http/Router/RoutePattern.php @@ -12,31 +12,57 @@ final class RoutePattern const PLACEHOLDER = '/\{\w+\??\}\??/'; /** - * @return null|array ['regex' => string, 'params' => [name => ['required' => bool]]]; null when the path has no placeholders + * @return array */ - public static function compile(string $path) + public static function placeholders(string $path): array { - if (preg_match_all(self::PLACEHOLDER, $path, $matched, PREG_OFFSET_CAPTURE) === false || empty($matched[0])) { - return; + if (preg_match_all(self::PLACEHOLDER, $path, $matched, PREG_OFFSET_CAPTURE) === false) { + return []; } - $regex = ''; - $params = []; - $cursor = 0; - foreach ($matched[0] as [$placeholder, $offset]) { - $name = trim($placeholder, '{}?'); + $placeholders = []; + $names = []; + foreach ($matched[0] ?? [] as [$token, $offset]) { + $name = trim($token, '{}?'); if (preg_match('/^[A-Za-z_]\w*$/', $name) !== 1) { throw new InvalidArgumentException("Invalid route parameter name [{$name}] in path [{$path}]."); } - if (isset($params[$name])) { + if (isset($names[$name])) { throw new InvalidArgumentException("Duplicate route parameter [{$name}] in path [{$path}]."); } - $required = !str_contains($placeholder, '?'); + $names[$name] = true; + $placeholders[] = [ + 'token' => $token, + 'offset' => $offset, + 'name' => $name, + 'required' => !str_contains($token, '?'), + ]; + } + + return $placeholders; + } + + /** + * @return null|array ['regex' => string, 'params' => [name => ['required' => bool]]]; null when the path has no placeholders + */ + public static function compile(string $path) + { + $placeholders = self::placeholders($path); + if (empty($placeholders)) { + return; + } + + $regex = ''; + $params = []; + $cursor = 0; + foreach ($placeholders as $placeholder) { + $name = $placeholder['name']; + $required = $placeholder['required']; $params[$name] = ['required' => $required]; - $literal = substr($path, $cursor, $offset - $cursor); - $cursor = $offset + \strlen($placeholder); + $literal = substr($path, $cursor, $placeholder['offset'] - $cursor); + $cursor = $placeholder['offset'] + \strlen($placeholder['token']); if (!$required && str_ends_with($literal, '/')) { // fold the separator into the optional group so "entries" matches "entries/{slug?}" diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 9c65836..a9b1ead 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -101,12 +101,12 @@ public static function isRewriteExists(?string $path = '', ?array $rewriteRules $rulesToCheck = $path ? ['^' . trim($path, '/')] : array_keys($rewriteRules); foreach ($rulesToCheck as $rule) { - if (isset($rules[$rule])) { - return true; + if (!isset($rules[$rule])) { + return false; } } - return false; + return true; } public function maybeFlushRewriteRules(): void diff --git a/tests/Router/RewriteRuleSetTest.php b/tests/Router/RewriteRuleSetTest.php index 6a5151f..911a10e 100644 --- a/tests/Router/RewriteRuleSetTest.php +++ b/tests/Router/RewriteRuleSetTest.php @@ -12,7 +12,31 @@ */ final class RewriteRuleSetTest extends TestCase { - public function testAddPathBuildsProgressiveRulesAndQueryVars(): void + public function testLiteralRouteRegistersItsFullRewrite(): void + { + $set = new RewriteRuleSet('landing'); + $set->addPath('about'); + + assertSameValue( + 'index.php?pagename=landing', + $set->rules()['^landing/about/?$'] ?? null, + 'literal route rewrite was not generated', + ); + } + + public function testOptionalParameterKeepsItsLiteralPrefix(): void + { + $set = new RewriteRuleSet('landing'); + $set->addPath('entries/{slug?}'); + + assertSameValue( + 'index.php?pagename=landing&slug=$matches[1]', + $set->rules()['^landing/entries(?:/([^/]+))?/?$'] ?? null, + 'optional route rewrite was malformed', + ); + } + + public function testAddPathBuildsOneCompleteRuleAndQueryVars(): void { $set = new RewriteRuleSet('landing'); $set->addPath('entries/{id}'); @@ -20,11 +44,10 @@ public function testAddPathBuildsProgressiveRulesAndQueryVars(): void assertSameValue( [ '^landing/?$' => 'index.php?pagename=landing', - '^landing/entries/?$' => 'index.php?pagename=landing', '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]', ], $set->rules(), - 'rewrite rule chain changed', + 'complete rewrite rule changed', ); assertSameValue(['id'], $set->queryVars(), 'query vars changed'); } diff --git a/tests/Router/RoutePatternTest.php b/tests/Router/RoutePatternTest.php index f13745b..2fb3234 100644 --- a/tests/Router/RoutePatternTest.php +++ b/tests/Router/RoutePatternTest.php @@ -13,6 +13,18 @@ */ final class RoutePatternTest extends TestCase { + public function testPlaceholdersExposeTokenOffsetsAndRequirements(): void + { + assertSameValue( + [ + ['token' => '{id}', 'offset' => 8, 'name' => 'id', 'required' => true], + ['token' => '{slug?}', 'offset' => 22, 'name' => 'slug', 'required' => false], + ], + RoutePattern::placeholders('entries/{id}/comments/{slug?}'), + 'placeholder metadata was not reusable by rewrite generation', + ); + } + public function testCompileBuildsNamedGroupsAndParamMetadata(): void { $compiled = RoutePattern::compile('entries/{id}/comments/{slug?}'); diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index f7ace9a..ab834c3 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -25,8 +25,8 @@ public function testRewriteRulesMapPageAndParameterSegmentsToQueryVars(): void $rules = WpKitTestState::$rewriteRules; assertSameValue('index.php?pagename=landing', $rules['^landing/?$']['query'] ?? null, 'page rewrite rule changed'); - assertSameValue('index.php?pagename=landing', $rules['^landing/entries/?$']['query'] ?? null, 'parameterless segment rewrite rule changed'); assertSameValue('index.php?pagename=landing&id=$matches[1]', $rules['^landing/entries/([^/]+)/?$']['query'] ?? null, 'parameter rewrite rule changed'); + assertTest(!isset($rules['^landing/entries/?$']), 'undeclared intermediate route was registered'); } public function testRewriteRulesForMultiParameterRouteChainQueryVars(): void @@ -38,16 +38,12 @@ public function testRewriteRulesForMultiParameterRouteChainQueryVars(): void do_action('init'); $rules = WpKitTestState::$rewriteRules; - assertSameValue( - 'index.php?pagename=landing&author=$matches[1]', - $rules['^landing/books/([^/]+)/chapters/?$']['query'] ?? null, - 'intermediate rewrite rule lost the first parameter', - ); assertSameValue( 'index.php?pagename=landing&author=$matches[1]&chapter=$matches[2]', $rules['^landing/books/([^/]+)/chapters/([^/]+)/?$']['query'] ?? null, 'full rewrite rule did not chain both parameters', ); + assertTest(!isset($rules['^landing/books/([^/]+)/chapters/?$']), 'undeclared intermediate route was registered'); } public function testInitWithoutRoutesRegistersNoRulesAndSkipsFlush(): void @@ -210,6 +206,7 @@ public function testInitSkipsFlushWhenStaticRulesAlreadyPersisted(): void return 'x'; }]); WpKitTestState::$options['rewrite_rules'] = [ + '^landing/?$' => 'index.php?pagename=landing', '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]', ]; @@ -227,6 +224,21 @@ public function testIsRewriteExistsChecksPersistedRulesByPath(): void assertTest(!StaticRouter::isRewriteExists(''), 'empty path was reported as existing'); } + public function testPersistedRewriteCheckRequiresEveryRule(): void + { + WpKitTestState::$options['rewrite_rules'] = [ + '^landing/?$' => 'index.php?pagename=landing', + ]; + + assertTest( + !StaticRouter::isRewriteExists('', [ + '^landing/?$' => 'index.php?pagename=landing', + '^landing/about/?$' => 'index.php?pagename=landing', + ]), + 'partial rewrite set was accepted as complete', + ); + } + private function makeTransport(array $routes = []): StaticRouter { new Router('static', 'landing', null); From 9eda702d97c1276d5fd6f3ede058f42054e201b8 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:25:22 +0600 Subject: [PATCH 18/38] fix(router): enforce static dispatch contract --- .../Router/Emitter/RawResponseEmitter.php | 11 +++++ .../Router/Emitter/StaticResponseEmitter.php | 21 ++++++++-- src/Http/Router/RouteRegister.php | 3 +- src/Http/Router/StaticRouter.php | 11 ++++- tests/Router/ResponseEmissionTest.php | 10 +++++ tests/Router/StaticRoutingTest.php | 40 +++++++++++++++++++ 6 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/Http/Router/Emitter/RawResponseEmitter.php diff --git a/src/Http/Router/Emitter/RawResponseEmitter.php b/src/Http/Router/Emitter/RawResponseEmitter.php new file mode 100644 index 0000000..0b734ba --- /dev/null +++ b/src/Http/Router/Emitter/RawResponseEmitter.php @@ -0,0 +1,11 @@ +getRouterType()) { RequestType::API => new Emitter\ApiResponseEmitter(), RequestType::AJAX => new Emitter\AjaxResponseEmitter(), - // static/web plus any custom type: hand the raw action output back to the caller - default => new Emitter\StaticResponseEmitter(), + default => new Emitter\RawResponseEmitter(), }; } } diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index a9b1ead..c7b4b45 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -18,7 +18,7 @@ class StaticRouter private array $queryVars = []; - private string $content; + private string $content = ''; public function __construct(string $pageName, string $activationHook, string $deactivationHook, ?Router $router = null) { @@ -61,12 +61,19 @@ public function addQueryVars($vars): array public function handleRequest(): void { $requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); + $method = strtoupper(sanitize_text_field($_SERVER['REQUEST_METHOD'] ?? 'GET')); foreach ($this->router->getRoutes() as $route) { + if (!\in_array($method, $route->getMethods(), true)) { + continue; + } + if ($this->isRouteMatched($route, $requestPath)) { // this filter needs to be added here to avoid affecting other routes add_filter('the_content', [$this, 'renderContent']); - $this->content = $route->handleRequest(); + $this->content = (new Emitter\StaticResponseEmitter())->emit([ + 'data' => ['data' => $route->handleRequest()], + ]); return; } diff --git a/tests/Router/ResponseEmissionTest.php b/tests/Router/ResponseEmissionTest.php index a5d258d..258d922 100644 --- a/tests/Router/ResponseEmissionTest.php +++ b/tests/Router/ResponseEmissionTest.php @@ -53,4 +53,14 @@ public function testNonStandardRouterTypeFallsBackToRawData(): void assertSameValue('raw-output', $route->handleRequest(), 'non-api/ajax dispatch no longer returns raw data'); } + + public function testNonStandardRouterTypePreservesRawArrayData(): void + { + new Router('cron', 'contract-test', null); + $route = (new RouteBase())->get('job', static function () { + return ['queued' => true]; + }); + + assertSameValue(['queued' => true], $route->handleRequest(), 'custom router array output changed'); + } } diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index ab834c3..907d96f 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -5,7 +5,9 @@ use BitApps\WPKit\Http\Router\RouteBase; use BitApps\WPKit\Http\Router\Router; use BitApps\WPKit\Http\Router\StaticRouter; +use BitApps\WPKit\Http\Router\Emitter\StaticResponseEmitter; use BitApps\WPKit\Tests\TestCase; +use UnexpectedValueException; use WpKitTestState; /** @@ -15,6 +17,44 @@ */ final class StaticRoutingTest extends TestCase { + public function testStaticPostRouteDoesNotExecuteOnGet(): void + { + $called = false; + new Router('static', 'landing', null); + (new RouteBase())->post('submit', static function () use (&$called) { + $called = true; + + return 'submitted'; + }); + new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/landing/submit'; + + do_action('template_redirect'); + + assertSameValue(false, $called, 'POST static action executed for GET'); + } + + public function testNullStaticOutputRendersAsEmptyContent(): void + { + $this->makeTransport(['empty' => static fn () => null]); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/landing/empty'; + + do_action('template_redirect'); + + assertSameValue('page', apply_filters('the_content', 'page'), 'null output did not normalize to empty HTML'); + } + + public function testArrayStaticOutputFailsWithAContractException(): void + { + assertThrows( + UnexpectedValueException::class, + static fn () => (new StaticResponseEmitter())->emit(['data' => ['data' => ['invalid']]]), + 'array static output was accepted', + ); + } + public function testRewriteRulesMapPageAndParameterSegmentsToQueryVars(): void { $this->makeTransport(['entries/{id}' => static function () { From e7e0053854e8f3a6f6cabde3cea473b5ecf27fe1 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:26:10 +0600 Subject: [PATCH 19/38] fix(router): preserve falsey route parameters --- src/Http/Router/RouteRegister.php | 17 +++++++++++------ tests/Router/DispatchTest.php | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php index 277e6f7..4ba53a2 100644 --- a/src/Http/Router/RouteRegister.php +++ b/src/Http/Router/RouteRegister.php @@ -281,11 +281,11 @@ public function handleRequest() private function resolveParamValue(ReflectionParameter $param) { - $value = !$param->isOptional() && $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; - - $paramName = $param->getName(); - if ($isRouteParam = $this->getRouteParamValue($paramName)) { - $value = $isRouteParam; + $value = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + $paramName = $param->getName(); + $hasRouteParam = $this->hasRouteParamValue($paramName); + if ($hasRouteParam) { + $value = $this->_routeParamValues[$paramName]; } if (!$type = $param->getType()) { @@ -305,7 +305,7 @@ private function resolveParamValue(ReflectionParameter $param) if ($type === Request::class || is_subclass_of($type, Request::class)) { $this->setRequest($type); $value = $this->resolveRequest(); - } elseif ($isRouteParam && $value === $isRouteParam && method_exists($type, '__construct')) { + } elseif ($hasRouteParam && method_exists($type, '__construct')) { $constructor = new ReflectionMethod($type, '__construct'); if ($constructor->getNumberOfParameters() === 1) { $parameter = $constructor->getParameters()[0]; @@ -322,6 +322,11 @@ private function resolveParamValue(ReflectionParameter $param) return $value; } + private function hasRouteParamValue(string $name): bool + { + return \array_key_exists($name, $this->_routeParamValues); + } + private function runMiddlewares(): void { if (empty($middlewares = $this->getMiddleware())) { diff --git a/tests/Router/DispatchTest.php b/tests/Router/DispatchTest.php index 29992cd..99b326e 100644 --- a/tests/Router/DispatchTest.php +++ b/tests/Router/DispatchTest.php @@ -259,6 +259,23 @@ public function testRouteDispatchClosureActionsReturnTheirData(): void assertSameValue(['result' => 'executed'], $response, 'static dispatch did not return action data'); } + public function testRouteDispatchInjectsStringZeroRouteParameter(): void + { + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('entries/{id}', static fn ($id) => $id); + $route->setRouteParamValue('id', '0'); + + assertSameValue('0', $route->handleRequest(), 'falsey route value was discarded'); + } + + public function testRouteDispatchUsesDeclaredActionDefault(): void + { + new Router('static', 'contract-test', null); + $route = (new RouteBase())->get('entries', static fn ($limit = 25) => $limit); + + assertSameValue(25, $route->handleRequest(), 'declared action default was replaced with null'); + } + public function testRouteDispatchResponseMetadataResetsBetweenRequests(): void { Response::error([])->message('stale error'); From 15649cde8929dce24a26203b28d9e3b41612f7cc Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:27:06 +0600 Subject: [PATCH 20/38] fix(http): isolate response lifecycle state --- src/Http/Response.php | 57 +++++++++++++++++++++---------------- tests/Http/ResponseTest.php | 27 ++++++++++++++++++ 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/Http/Response.php b/src/Http/Response.php index 8fbecc7..9d80924 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -54,13 +54,7 @@ public static function adopt(self $response): self */ public static function success($data, $httpStatus = 200): self { - $current = self::current(); - $current->_data = $data; - $current->_status = self::SUCCESS; - - $current->_httpStatus = $httpStatus; - - return $current; + return self::start($data, self::SUCCESS, $httpStatus); } /** @@ -73,13 +67,7 @@ public static function success($data, $httpStatus = 200): self */ public static function error($data, $httpStatus = 400): self { - $current = self::current(); - $current->_data = $data; - $current->_status = self::ERROR; - - $current->_httpStatus = $httpStatus; - - return $current; + return self::start($data, self::ERROR, $httpStatus); } /** @@ -201,12 +189,16 @@ public static function headers($headers): Response throw new InvalidArgumentException('Response headers must be an array.'); } - self::current()->_headers = []; + $validated = []; foreach ($headers as $header => $value) { - self::header($header, $value); + [$header, $value] = self::validateHeader($header, $value); + $validated[$header] = $value; } - return self::current(); + $current = self::current(); + $current->_headers = $validated; + + return $current; } /** @@ -219,13 +211,7 @@ public static function headers($headers): Response */ public static function header($header, $value): self { - if (!\is_string($header) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $header) !== 1) { - throw new InvalidArgumentException('Invalid response header name.'); - } - - if (!\is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) { - throw new InvalidArgumentException('Invalid response header value.'); - } + [$header, $value] = self::validateHeader($header, $value); $current = self::current(); $current->_headers[$header] = $value; @@ -243,6 +229,29 @@ public static function getHeaders(): array return self::current()->_headers; } + private static function start($data, string $status, $httpStatus): self + { + $response = new self(); + $response->_data = $data; + $response->_status = $status; + $response->_httpStatus = $httpStatus; + + return self::$_current = $response; + } + + private static function validateHeader($header, $value): array + { + if (!\is_string($header) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $header) !== 1) { + throw new InvalidArgumentException('Invalid response header name.'); + } + + if (!\is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) { + throw new InvalidArgumentException('Invalid response header value.'); + } + + return [$header, $value]; + } + private static function current(): Response { if (\is_null(self::$_current)) { diff --git a/tests/Http/ResponseTest.php b/tests/Http/ResponseTest.php index ead2bbd..9c37309 100644 --- a/tests/Http/ResponseTest.php +++ b/tests/Http/ResponseTest.php @@ -13,6 +13,33 @@ */ final class ResponseTest extends TestCase { + public function testSuccessFactoryDoesNotRetainPriorMetadata(): void + { + Response::error(['old'])->message('old')->code('OLD')->header('X-Old', 'yes'); + + $response = Response::success(['new']); + + assertSameValue(null, $response->getMessage(), 'success retained an old message'); + assertSameValue('SUCCESS', $response->getCode(), 'success retained an old code'); + assertSameValue([], $response->getHeaders(), 'success retained old headers'); + } + + public function testInvalidBulkHeadersDoNotPartiallyReplaceExistingHeaders(): void + { + Response::header('X-Existing', 'yes'); + + try { + Response::headers(['X-Valid' => 'yes', "Bad\r\nName" => 'no']); + } catch (InvalidArgumentException) { + } + + assertSameValue( + ['X-Existing' => 'yes'], + Response::getHeaders(), + 'invalid bulk headers partially replaced the existing collection', + ); + } + public function testResponseSuccessFactoryExposesDataMetadataAndChaining(): void { $response = Response::success(['id' => 42], 201) From 514905b51cd09be5e1ca9c43e787b3c7f58aacb7 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:29:54 +0600 Subject: [PATCH 21/38] fix(http): build valid multipart requests --- src/Http/Client/HttpClient.php | 100 ++++++++++++++++++++++++++------ tests/Http/HttpClientTest.php | 103 +++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 19 deletions(-) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index ed26340..d13e013 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -52,13 +52,10 @@ public function __construct(array $config = []) public function __call(string $method, array $params) { - if (\in_array($method, ['post', 'get', 'put','patch', 'delete', 'head', 'option'])) { + $method = strtolower($method); + if (\in_array($method, ['post', 'get', 'put', 'patch', 'delete', 'head', 'option', 'options'], true)) { $this->_method = $method; - $url = $this->_baseUri . $params[0]; - $query = http_build_query($this->getQueryParams()); - if (!empty($query)) { - $url = $url . '?' . $query; - } + $url = $this->buildUrl((string) ($params[0] ?? '')); $data = $this->getPreparedPayload(); $headers = $this->getHeaders(); @@ -155,7 +152,10 @@ public function setAllowedUnsafeHosts(array $hosts): self public function setBoundary($boundary): self { - $this->_boundary = '-------' . (string) $boundary; + $this->_boundary = '-------' . $this->validateMultipartMetadata($boundary); + if (!empty($this->_multipart)) { + $this->_headers['Content-Type'] = ['multipart/form-data; boundary=' . $this->_boundary]; + } return $this; } @@ -362,8 +362,8 @@ public function getFormParams() public function setMultipart($data): self { - $this->setContentType('multipart/form-data; charset=UTF-8'); $this->_multipart = $data; + $this->setMultipartContentType(); return $this; } @@ -377,7 +377,7 @@ public function getPreparedPayload() { $payload = null; if (!empty($this->_multipart)) { - if (!empty($this->getBody()) && !empty($this->getFormParams()) && !empty($this->getJson())) { + if (!empty($this->getBody()) || !empty($this->getFormParams()) || !empty($this->getJson())) { throw new InvalidArgumentException('Do not use multipart with json, params or body'); } @@ -403,41 +403,103 @@ public function getPreparedPayload() public function getPreparedMultipart(): string { $multipart = ''; + $boundary = $this->getBoundary(); + $this->setMultipartContentType(); if (!empty($this->getMultipart()) && \is_array($this->getMultipart())) { foreach ($this->getMultipart() as $part) { if (\is_array($part) && isset($part['name'], $part['contents'])) { - $multipart .= '--' . $this->getBoundary() . '\r\n'; - $multipart .= 'Content-Disposition: form-data; name="' . $part['name'] . '"'; + $multipart .= '--' . $boundary . "\r\n"; + $multipart .= 'Content-Disposition: form-data; name="' . $this->quoteMultipartValue($part['name']) . '"'; if (isset($part['filename'])) { - $multipart .= ';filename="' . $part['filename'] . '"'; + $multipart .= '; filename="' . $this->quoteMultipartValue($part['filename']) . '"'; } - $multipart .= '\r\n'; + $multipart .= "\r\n"; if (isset($part['headers'])) { if (\is_array($part['headers'])) { foreach ($part['headers'] as $key => $value) { - $multipart .= $key . ':'; - $multipart .= \is_array($value) ? implode(';', $value) : $value; - $multipart .= '\r\n'; + $multipart .= $this->validateMultipartHeaderName($key) . ': '; + $multipart .= $this->validateMultipartHeaderValue($value); + $multipart .= "\r\n"; } } elseif (\is_string($part['headers'])) { - $multipart .= $part['headers'] . '\r\n'; + $multipart .= $this->validateMultipartMetadata($part['headers']) . "\r\n"; + } else { + throw new InvalidArgumentException('Invalid multipart headers.'); } } + $multipart .= "\r\n"; $multipart .= $part['contents']; - $multipart .= '\r\n'; + $multipart .= "\r\n"; } else { throw new InvalidArgumentException('Multipart must contain name, contents'); } } } - $multipart .= '--' . $this->getBoundary() . '--'; + $multipart .= '--' . $boundary . "--\r\n"; return $multipart; } + private function buildUrl(string $path): string + { + $baseUri = (string) $this->_baseUri; + $url = $baseUri === '' ? $path : rtrim($baseUri, '/') . '/' . ltrim($path, '/'); + $query = http_build_query($this->getQueryParams()); + if ($query === '') { + return $url; + } + + $fragment = ''; + if (($fragmentPosition = strpos($url, '#')) !== false) { + $fragment = substr($url, $fragmentPosition); + $url = substr($url, 0, $fragmentPosition); + } + + return $url . (str_contains($url, '?') ? '&' : '?') . $query . $fragment; + } + + private function setMultipartContentType(): void + { + $this->_headers['Content-Type'] = ['multipart/form-data; boundary=' . $this->getBoundary()]; + } + + private function quoteMultipartValue($value): string + { + return addcslashes($this->validateMultipartMetadata($value), '\\"'); + } + + private function validateMultipartMetadata($value): string + { + if (!\is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) { + throw new InvalidArgumentException('Invalid multipart metadata.'); + } + + return (string) $value; + } + + private function validateMultipartHeaderName(int|string $name): string + { + if (!\is_string($name) || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $name) !== 1) { + throw new InvalidArgumentException('Invalid multipart header name.'); + } + + return $name; + } + + private function validateMultipartHeaderValue($value): string + { + if (\is_array($value)) { + $value = array_map([$this, 'validateMultipartMetadata'], $value); + + return implode(';', $value); + } + + return $this->validateMultipartMetadata($value); + } + private function normalizeHost($host): string { if (!\is_string($host)) { diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index de949e1..2bf9f2d 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -6,6 +6,7 @@ use BitApps\WPKit\Http\Client\HttpClient; use BitApps\WPKit\Tests\TestCase; use FakeWpError; +use InvalidArgumentException; use WP_Error; use WpKitTestState; @@ -16,6 +17,108 @@ */ final class HttpClientTest extends TestCase { + public function testHTTPClientOptionsMagicMethodUsesOptionsVerb(): void + { + (new HttpClient(['base_uri' => 'https://example.com']))->options('/status'); + + assertSameValue('OPTIONS', WpKitTestState::$lastHttpRequest['options']['method'], 'OPTIONS verb was unavailable'); + } + + public function testHTTPClientQueryParametersAppendToExistingQueryString(): void + { + (new HttpClient(['base_uri' => 'https://example.com'])) + ->setQueryParams(['page' => 2]) + ->get('/items?active=1'); + + assertSameValue( + 'https://example.com/items?active=1&page=2', + WpKitTestState::$lastHttpRequest['url'], + 'query string was malformed', + ); + } + + public function testHTTPClientMultipartUsesRealCrlfAndBoundaryHeader(): void + { + $client = (new HttpClient())->setBoundary('test')->setMultipart([ + ['name' => 'file', 'contents' => 'data', 'filename' => 'a.txt'], + ]); + + $payload = $client->getPreparedPayload(); + + assertTest(str_contains($payload, "\r\n"), 'multipart body contains no CRLF'); + assertTest(!str_contains($payload, '\\r\\n'), 'multipart body contains escaped CRLF text'); + assertSameValue( + 'multipart/form-data; boundary=-------test', + $client->getHeaders()['Content-Type'], + 'boundary missing from content type', + ); + } + + public function testHTTPClientMultipartRejectsAnyConflictingPayloadMode(): void + { + $client = (new HttpClient()) + ->setMultipart([['name' => 'a', 'contents' => 'b']]) + ->setJson(['x' => 1]); + + assertThrows( + InvalidArgumentException::class, + static fn () => $client->getPreparedPayload(), + 'multipart and JSON were combined', + ); + } + + public function testHTTPClientMultipartRejectsHeaderInjectionInFilename(): void + { + $client = (new HttpClient())->setMultipart([ + ['name' => 'file', 'contents' => 'data', 'filename' => "a.txt\r\nX-Evil: yes"], + ]); + + assertThrows( + InvalidArgumentException::class, + static fn () => $client->getPreparedPayload(), + 'multipart filename accepted CRLF', + ); + } + + public function testHTTPClientMultipartRejectsHeaderInjectionInBoundary(): void + { + assertThrows( + InvalidArgumentException::class, + static fn () => (new HttpClient())->setBoundary("safe\r\nX-Evil: yes"), + 'multipart boundary accepted CRLF', + ); + } + + public function testHTTPClientMultipartRejectsHeaderInjectionInFieldName(): void + { + $client = (new HttpClient())->setMultipart([ + ['name' => "file\r\nX-Evil: yes", 'contents' => 'data'], + ]); + + assertThrows( + InvalidArgumentException::class, + static fn () => $client->getPreparedPayload(), + 'multipart field name accepted CRLF', + ); + } + + public function testHTTPClientMultipartRejectsInjectedPartHeaders(): void + { + $client = (new HttpClient())->setMultipart([ + [ + 'name' => 'file', + 'contents' => 'data', + 'headers' => ["X-Safe\r\nX-Evil" => 'yes'], + ], + ]); + + assertThrows( + InvalidArgumentException::class, + static fn () => $client->getPreparedPayload(), + 'multipart part header accepted CRLF', + ); + } + public function testHTTPClientSafeRemoteRequestsAreTheDefault(): void { $client = new HttpClient(); From a45032ef7f8e212e4c552e70f2805a1d97bbe75a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:33:21 +0600 Subject: [PATCH 22/38] refactor(http): simplify client detection --- src/Http/Detection/UserAgent.php | 382 +++++++++++----------------- src/Http/IpTool.php | 6 +- tests/Http/ClientIpResolverTest.php | 9 + tests/Http/UserAgentTest.php | 47 ++++ 4 files changed, 212 insertions(+), 232 deletions(-) diff --git a/src/Http/Detection/UserAgent.php b/src/Http/Detection/UserAgent.php index e6ba1fb..438409a 100644 --- a/src/Http/Detection/UserAgent.php +++ b/src/Http/Detection/UserAgent.php @@ -7,14 +7,47 @@ */ final class UserAgent { + private const BROWSERS = [ + 'Opera' => ['opera', 'opr/'], + 'Edge' => ['edge', 'edg/'], + 'Chrome' => ['chrome'], + 'Safari' => ['safari'], + 'Firefox' => ['firefox'], + 'Internet Explorer' => ['msie', 'trident/7'], + ]; + + private const BOTS = [ + 'Googlebot' => ['google'], + 'Bingbot' => ['bing'], + 'Yahoo! Slurp' => ['slurp'], + 'DuckDuckBot' => ['duckduckgo'], + 'Baidu' => ['baidu'], + 'Yandex' => ['yandex'], + 'Sogou' => ['sogou'], + 'Exabot' => ['exabot'], + 'MSN' => ['msn'], + 'Majestic' => ['mj12bot'], + 'Ahrefs' => ['ahrefs'], + 'SEMRush' => ['semrush'], + 'Moz' => ['rogerbot', 'dotbot'], + 'Screaming Frog' => ['frog', 'screaming'], + 'Facebook' => ['facebook'], + 'Pinterest' => ['pinterest'], + 'Bot' => ['crawler', 'api', 'spider', 'http', 'bot', 'archive', 'info', 'data'], + ]; + /** * Check device info. */ public static function checkDevice(): string { - return isset( - $_SERVER['HTTP_USER_AGENT'] - ) ? self::getBrowserName(wp_kses($_SERVER['HTTP_USER_AGENT'], [])) . '|' . self::getOS(wp_kses($_SERVER['HTTP_USER_AGENT'], [])) : ''; + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return ''; + } + + $userAgent = wp_kses($_SERVER['HTTP_USER_AGENT'], []); + + return self::getBrowserName($userAgent) . '|' . self::getOS($userAgent); } /** @@ -26,119 +59,13 @@ public static function checkDevice(): string */ private static function getBrowserName($userAgent): string { - // Make case insensitive. - $t = strtolower($userAgent); - - // If the string *starts* with the string, strpos returns 0 (i.e., FALSE). Do a ghetto hack and start with a space. - // "[strpos()] may return Boolean FALSE, but may also return a non-Boolean value which evaluates to FALSE." - // http://php.net/manual/en/function.strpos.php - $t = ' ' . $t; - - // Humans / Regular Users - if (strpos($t, 'opera') || strpos($t, 'opr/')) { - return 'Opera'; - } - - if (strpos($t, 'edge')) { - return 'Edge'; - } - - if (strpos($t, 'Edg')) { - return 'Edge'; - } - - if (strpos($t, 'chrome')) { - return 'Chrome'; - } - - if (strpos($t, 'safari')) { - return 'Safari'; - } - - if (strpos($t, 'firefox')) { - return 'Firefox'; - } - - if (strpos($t, 'msie') || strpos($t, 'trident/7')) { - return 'Internet Explorer'; - } - - if (strpos($t, 'google')) { - return 'Googlebot'; - } - - if (strpos($t, 'bing')) { - return 'Bingbot'; - } - - if (strpos($t, 'slurp')) { - return 'Yahoo! Slurp'; - } - - if (strpos($t, 'duckduckgo')) { - return 'DuckDuckBot'; - } - - if (strpos($t, 'baidu')) { - return 'Baidu'; - } - - if (strpos($t, 'yandex')) { - return 'Yandex'; - } - - if (strpos($t, 'sogou')) { - return 'Sogou'; - } - - if (strpos($t, 'exabot')) { - return 'Exabot'; - } - - if (strpos($t, 'msn')) { - return 'MSN'; - } - - // Common Tools and Bots - if (strpos($t, 'mj12bot')) { - return 'Majestic'; - } - - if (strpos($t, 'ahrefs')) { - return 'Ahrefs'; - } - - if (strpos($t, 'semrush')) { - return 'SEMRush'; - } - - if (strpos($t, 'rogerbot') || strpos($t, 'dotbot')) { - return 'Moz'; - } - - if (strpos($t, 'frog') || strpos($t, 'screaming')) { - return 'Screaming Frog'; - } - - if (strpos($t, 'facebook')) { - return 'Facebook'; - } - - if (strpos($t, 'pinterest')) { - return 'Pinterest'; - } - - if ( - strpos($t, 'crawler') - || strpos($t, 'api') - || strpos($t, 'spider') - || strpos($t, 'http') - || strpos($t, 'bot') - || strpos($t, 'archive') - || strpos($t, 'info') - || strpos($t, 'data') - ) { - return 'Bot'; + $userAgent = strtolower((string) $userAgent); + foreach (self::BROWSERS + self::BOTS as $name => $tokens) { + foreach ($tokens as $token) { + if (str_contains($userAgent, $token)) { + return $name; + } + } } return 'Other (Unknown)'; @@ -153,122 +80,119 @@ private static function getBrowserName($userAgent): string */ private static function getOS($userAgent): string { - $ros[] = ['Windows XP', 'Windows XP']; - $ros[] = ['Windows NT 5.1|Windows NT5.1', 'Windows XP']; - $ros[] = ['Windows 2000', 'Windows 2000']; - $ros[] = ['Windows NT 5.0', 'Windows 2000']; - $ros[] = ['Windows NT 4.0|WinNT4.0', 'Windows NT']; - $ros[] = ['Windows NT 5.2', 'Windows Server 2003']; - $ros[] = ['Windows NT 6.0', 'Windows Vista']; - $ros[] = ['Windows NT 7.0', 'Windows 7']; - $ros[] = ['Windows CE', 'Windows CE']; - $ros[] = [ - '(media center pc).([0-9]{1,2}\.[0-9]{1,2})', - 'Windows Media Center', + $ros = [ + ['Windows XP', 'Windows XP'], + ['Windows NT 5.1|Windows NT5.1', 'Windows XP'], + ['Windows 2000', 'Windows 2000'], + ['Windows NT 5.0', 'Windows 2000'], + ['Windows NT 4.0|WinNT4.0', 'Windows NT'], + ['Windows NT 5.2', 'Windows Server 2003'], + ['Windows NT 6.0', 'Windows Vista'], + ['Windows NT 7.0', 'Windows 7'], + ['Windows CE', 'Windows CE'], + [ + '(media center pc).([0-9]{1,2}\.[0-9]{1,2})', + 'Windows Media Center', + ], + ['(win)([0-9]{1,2}\.[0-9x]{1,2})', 'Windows'], + ['(win)([0-9]{2})', 'Windows'], + ['(windows)([0-9x]{2})', 'Windows'], + ['Windows ME', 'Windows ME'], + ['Win 9x 4.90', 'Windows ME'], + ['Windows 98|Win98', 'Windows 98'], + ['Windows 95', 'Windows 95'], + ['(windows)([0-9]{1,2}\.[0-9]{1,2})', 'Windows'], + ['win32', 'Windows'], + ['(java)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2})', 'Java'], + ['(Solaris)([0-9]{1,2}\.[0-9x]{1,2}){0,1}', 'Solaris'], + ['dos x86', 'DOS'], + ['unix', 'Unix'], + // Android + ['SM', 'Samsung'], + ['HTC', 'HTC'], + ['LG', 'LG'], + ['Microsoft', 'Microsoft'], + ['Pixel', 'Pixel'], + ['MI', 'Xiaomi'], + ['Xiaomi', 'Xiaomi'], + ['Android', 'Android'], + ['android', 'Android'], + + // iPhone + ['iPhone', 'iPhone'], + + ['Mac OS X', 'Mac OS X'], + ['Mac OS X Puma', 'Mac OS X 10.1[^0-9]'], + ['Mac_PowerPC', 'Macintosh PowerPC'], + ['(mac|Macintosh)', 'Mac OS'], + ['(sunos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'SunOS'], + ['(beos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'BeOS'], + ['(risc os)([0-9]{1,2}\.[0-9]{1,2})', 'RISC OS'], + ['os\/2', 'OS/2'], + ['freebsd', 'FreeBSD'], + ['openbsd', 'OpenBSD'], + ['netbsd', 'NetBSD'], + ['irix', 'IRIX'], + ['plan9', 'Plan9'], + ['osf', 'OSF'], + ['aix', 'AIX'], + ['GNU Hurd', 'GNU Hurd'], + ['(fedora)', 'Linux - Fedora'], + ['(kubuntu)', 'Linux - Kubuntu'], + ['(ubuntu)', 'Linux - Ubuntu'], + ['(debian)', 'Linux - Debian'], + ['(CentOS)', 'Linux - CentOS'], + [ + '(Mandriva).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)', + 'Linux - Mandriva', + ], + [ + '(SUSE).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)', + 'Linux - SUSE', + ], + ['(Dropline)', 'Linux - Slackware (Dropline GNOME)'], + ['(ASPLinux)', 'Linux - ASPLinux'], + ['(Red Hat)', 'Linux - Red Hat'], + ['(linux)', 'Linux'], + ['(amigaos)([0-9]{1,2}\.[0-9]{1,2})', 'AmigaOS'], + ['amiga-aweb', 'AmigaOS'], + ['amiga', 'Amiga'], + ['AvantGo', 'PalmOS'], + ['(webtv)/([0-9]{1,2}\.[0-9]{1,2})', 'WebTV'], + ['Dreamcast', 'Dreamcast OS'], + ['GetRight', 'Windows'], + ['go!zilla', 'Windows'], + ['gozilla', 'Windows'], + ['gulliver', 'Windows'], + ['ia archiver', 'Windows'], + ['NetPositive', 'Windows'], + ['mass downloader', 'Windows'], + ['microsoft', 'Windows'], + ['offline explorer', 'Windows'], + ['teleport', 'Windows'], + ['web downloader', 'Windows'], + ['webcapture', 'Windows'], + ['webcollage', 'Windows'], + ['webcopier', 'Windows'], + ['webstripper', 'Windows'], + ['webzip', 'Windows'], + ['wget', 'Windows'], + ['Java', 'Unknown'], + ['flashget', 'Windows'], + ['MS FrontPage', 'Windows'], + ['(msproxy)/([0-9]{1,2}.[0-9]{1,2})', 'Windows'], + ['(msie)([0-9]{1,2}.[0-9]{1,2})', 'Windows'], + ['libwww-perl', 'Unix'], + ['UP.Browser', 'Windows CE'], + ['NetAnts', 'Windows'], + ['Android', 'Android'], ]; - $ros[] = ['(win)([0-9]{1,2}\.[0-9x]{1,2})', 'Windows']; - $ros[] = ['(win)([0-9]{2})', 'Windows']; - $ros[] = ['(windows)([0-9x]{2})', 'Windows']; - $ros[] = ['Windows ME', 'Windows ME']; - $ros[] = ['Win 9x 4.90', 'Windows ME']; - $ros[] = ['Windows 98|Win98', 'Windows 98']; - $ros[] = ['Windows 95', 'Windows 95']; - $ros[] = ['(windows)([0-9]{1,2}\.[0-9]{1,2})', 'Windows']; - $ros[] = ['win32', 'Windows']; - $ros[] = ['(java)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2})', 'Java']; - $ros[] = ['(Solaris)([0-9]{1,2}\.[0-9x]{1,2}){0,1}', 'Solaris']; - $ros[] = ['dos x86', 'DOS']; - $ros[] = ['unix', 'Unix']; - // Android - $ros[] = ['SM', 'Samsung']; - $ros[] = ['HTC', 'HTC']; - $ros[] = ['LG', 'LG']; - $ros[] = ['Microsoft', 'Microsoft']; - $ros[] = ['Pixel', 'Pixel']; - $ros[] = ['MI', 'Xiaomi']; - $ros[] = ['Xiaomi', 'Xiaomi']; - $ros[] = ['Android', 'Android']; - $ros[] = ['android', 'Android']; - - // iPhone - $ros[] = ['iPhone', 'iPhone']; - - $ros[] = ['Mac OS X', 'Mac OS X']; - $ros[] = ['Mac OS X Puma', 'Mac OS X 10.1[^0-9]']; - $ros[] = ['Mac_PowerPC', 'Macintosh PowerPC']; - $ros[] = ['(mac|Macintosh)', 'Mac OS']; - $ros[] = ['(sunos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'SunOS']; - $ros[] = ['(beos)([0-9]{1,2}\.[0-9]{1,2}){0,1}', 'BeOS']; - $ros[] = ['(risc os)([0-9]{1,2}\.[0-9]{1,2})', 'RISC OS']; - $ros[] = ['os\/2', 'OS/2']; - $ros[] = ['freebsd', 'FreeBSD']; - $ros[] = ['openbsd', 'OpenBSD']; - $ros[] = ['netbsd', 'NetBSD']; - $ros[] = ['irix', 'IRIX']; - $ros[] = ['plan9', 'Plan9']; - $ros[] = ['osf', 'OSF']; - $ros[] = ['aix', 'AIX']; - $ros[] = ['GNU Hurd', 'GNU Hurd']; - $ros[] = ['(fedora)', 'Linux - Fedora']; - $ros[] = ['(kubuntu)', 'Linux - Kubuntu']; - $ros[] = ['(ubuntu)', 'Linux - Ubuntu']; - $ros[] = ['(debian)', 'Linux - Debian']; - $ros[] = ['(CentOS)', 'Linux - CentOS']; - $ros[] = [ - '(Mandriva).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)', - 'Linux - Mandriva', - ]; - $ros[] = [ - '(SUSE).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)', - 'Linux - SUSE', - ]; - $ros[] = ['(Dropline)', 'Linux - Slackware (Dropline GNOME)']; - $ros[] = ['(ASPLinux)', 'Linux - ASPLinux']; - $ros[] = ['(Red Hat)', 'Linux - Red Hat']; - $ros[] = ['(linux)', 'Linux']; - $ros[] = ['(amigaos)([0-9]{1,2}\.[0-9]{1,2})', 'AmigaOS']; - $ros[] = ['amiga-aweb', 'AmigaOS']; - $ros[] = ['amiga', 'Amiga']; - $ros[] = ['AvantGo', 'PalmOS']; - $ros[] = ['[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,3})', 'Linux']; - $ros[] = ['(webtv)/([0-9]{1,2}\.[0-9]{1,2})', 'WebTV']; - $ros[] = ['Dreamcast', 'Dreamcast OS']; - $ros[] = ['GetRight', 'Windows']; - $ros[] = ['go!zilla', 'Windows']; - $ros[] = ['gozilla', 'Windows']; - $ros[] = ['gulliver', 'Windows']; - $ros[] = ['ia archiver', 'Windows']; - $ros[] = ['NetPositive', 'Windows']; - $ros[] = ['mass downloader', 'Windows']; - $ros[] = ['microsoft', 'Windows']; - $ros[] = ['offline explorer', 'Windows']; - $ros[] = ['teleport', 'Windows']; - $ros[] = ['web downloader', 'Windows']; - $ros[] = ['webcapture', 'Windows']; - $ros[] = ['webcollage', 'Windows']; - $ros[] = ['webcopier', 'Windows']; - $ros[] = ['webstripper', 'Windows']; - $ros[] = ['webzip', 'Windows']; - $ros[] = ['wget', 'Windows']; - $ros[] = ['Java', 'Unknown']; - $ros[] = ['flashget', 'Windows']; - $ros[] = ['MS FrontPage', 'Windows']; - $ros[] = ['(msproxy)/([0-9]{1,2}.[0-9]{1,2})', 'Windows']; - $ros[] = ['(msie)([0-9]{1,2}.[0-9]{1,2})', 'Windows']; - $ros[] = ['libwww-perl', 'Unix']; - $ros[] = ['UP.Browser', 'Windows CE']; - $ros[] = ['NetAnts', 'Windows']; - $ros[] = ['Android', 'Android']; - $file = \count($ros); - $os = ''; - for ($n = 0; $n < $file; ++$n) { - if (@preg_match('/' . $ros[$n][0] . '/i', $userAgent)) { - $os = @$ros[$n][1]; - - break; + foreach ($ros as [$pattern, $name]) { + if (preg_match('~' . $pattern . '~i', (string) $userAgent) === 1) { + return $name; } } - return trim($os); + return ''; } } diff --git a/src/Http/IpTool.php b/src/Http/IpTool.php index d07ab40..79859fb 100644 --- a/src/Http/IpTool.php +++ b/src/Http/IpTool.php @@ -14,7 +14,7 @@ trait IpTool /** * Provide user details. * - * @return setUserDetail user details array + * @return array user details array */ public static function getUserDetail() { @@ -24,7 +24,7 @@ public static function getUserDetail() /** * Provide user IP address. * - * @return ip + * @return string|false IP address of current visitor */ public static function ip() { @@ -60,7 +60,7 @@ public function user() * * @return array of user details */ - private static function setUserDetail() + private static function setUserDetail(): array { $userDetails['ip'] = ip2long(ClientIpResolver::checkIP()); $userDetails['device'] = UserAgent::checkDevice(); diff --git a/tests/Http/ClientIpResolverTest.php b/tests/Http/ClientIpResolverTest.php index 9c1ee7e..75020f8 100644 --- a/tests/Http/ClientIpResolverTest.php +++ b/tests/Http/ClientIpResolverTest.php @@ -94,6 +94,15 @@ public function testClientIPInvalidCIDRPrefixLengthsAreRejected(): void assertSameValue('10.0.0.2', Request::ip(), 'peer matched an invalid CIDR prefix'); } + public function testClientIPInvalidProxyEntriesDoNotDisableValidRanges(): void + { + Request::setTrustedProxies(['invalid', '10.0.0.0/999', '10.0.0.0/8']); + $_SERVER['REMOTE_ADDR'] = '10.0.0.2'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.10'; + + assertSameValue('203.0.113.10', Request::ip(), 'invalid proxy entries disabled a valid trusted range'); + } + public function testClientIPPartialByteCIDRMasksMatchValidPeers(): void { Request::setTrustedProxies(['10.0.0.0/9']); diff --git a/tests/Http/UserAgentTest.php b/tests/Http/UserAgentTest.php index 582d387..211e7f6 100644 --- a/tests/Http/UserAgentTest.php +++ b/tests/Http/UserAgentTest.php @@ -12,6 +12,53 @@ */ final class UserAgentTest extends TestCase { + public function testModernEdgeIsNotClassifiedAsChrome(): void + { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 Chrome/120.0 Safari/537.36 Edg/120.0'; + + assertSameValue('Edge|', UserAgent::checkDevice(), 'modern Edge was classified as Chrome'); + } + + public function testUserAgentIsSanitizedBeforeClassification(): void + { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 Firefox/120.0'; + + assertSameValue('Firefox|', UserAgent::checkDevice(), 'sanitized browser classification changed'); + } + + public function testKnownCrawlerKeepsItsSpecificLabel(): void + { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (compatible; Googlebot/2.1)'; + + assertSameValue('Googlebot|', UserAgent::checkDevice(), 'specific crawler was reduced to a generic bot'); + } + + public function testOperatingSystemPriorityRemainsStable(): void + { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) Chrome/120.0'; + + assertSameValue('Chrome|Pixel', UserAgent::checkDevice(), 'device-specific OS priority changed'); + } + + public function testOperatingSystemDetectionEmitsNoRegexWarnings(): void + { + $_SERVER['HTTP_USER_AGENT'] = 'Legacy client 1.2.3'; + $warnings = []; + set_error_handler(static function ($severity, $message) use (&$warnings): bool { + $warnings[] = [$severity, $message]; + + return true; + }); + + try { + UserAgent::checkDevice(); + } finally { + restore_error_handler(); + } + + assertSameValue([], $warnings, 'OS detection suppressed an invalid regular expression'); + } + public function testDeviceStringCombinesBrowserAndOS(): void { $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; From 7eb43e017848e1e88efc70d58562fd73c8198b51 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:34:49 +0600 Subject: [PATCH 23/38] docs: document hardened HTTP contracts --- README.md | 26 ++++++++++++++++++++++++-- tests/README.md | 7 +++++++ tests/coverage.php | 6 +++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fcb64bc..6061a22 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,14 @@ $body = $client->request('https://api.example.com/hooks', 'POST', ['event' => $code = $client->getResponseCode(); ``` +Dynamic instance verbs include `get`, `post`, `put`, `patch`, `delete`, `head`, +and `options`. Query parameters are appended safely when the request path already +contains a query string. + +Multipart requests use standards-compliant boundaries and CRLF framing. Multipart +mode cannot be combined with JSON, form parameters, or a separate request body; +field names, filenames, boundaries, and part headers reject control characters. + Safe by default (`wp_safe_remote_request`). To reach known internal hosts, an administrator must explicitly enable unsafe URLs and allowlist each exact trusted endpoint host: @@ -160,7 +168,9 @@ Shortcode::addShortcode('myplugin_widget', [$plugin, 'renderWidget']); `Installer` handles activation/deactivation/uninstall and requirement checks; `Migration` + `MigrationHelper` run schema migrations; `StaticRouter` maps custom -front-end page URLs (via rewrite rules) to routes. +front-end page URLs (via rewrite rules) to routes. Static routes enforce their +declared HTTP methods. Their actions must return string-compatible page content; +`null` renders no additional content. ## Components @@ -234,9 +244,21 @@ front-end page URLs (via rewrite rules) to routes. invalid parameter names throw `InvalidArgumentException` at registration. Trade-off: an optional param no longer matches the empty-value-with-trailing- slash form (`entries/`) on AJAX routes — use `entries` (no slash) instead. +- Static routes now generate one complete, anchored rewrite rule for every + declared path, including literal and optional-parameter paths. Undeclared + intermediate prefixes are no longer registered as routes. +- Static page dispatch now enforces the route's declared HTTP methods and accepts + only string-compatible output. Direct `RouteRegister::handleRequest()` and + custom router types continue returning raw values for backward compatibility. +- `Response::success()` and `Response::error()` now start with fresh metadata; + messages, codes, and headers no longer leak from an earlier factory response. + Bulk header changes are validated completely before replacing existing headers. +- `Request::input()` and the other frozen request/IP extension points intentionally + remain untyped so downstream subclasses with legacy signatures remain compatible. - IP/device detection classes moved to `Http\Detection\` (`ClientIpResolver`, `UserAgent`). The `Http\IpTool` trait and `Request::ip()`/`device()` facade - are unchanged. + are unchanged. Modern `Edg/` user agents are recognized as Edge, and OS matching + no longer suppresses malformed regular-expression warnings. ## Tests diff --git a/tests/README.md b/tests/README.md index f2bfd15..bbd3ca3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,10 +15,17 @@ must be corrected rather than treated as compatibility requirements. Run the suite and package coverage report with: ```bash +composer validate --no-check-publish --no-interaction +composer lint +composer compat +composer rector composer test composer coverage ``` +Development dependencies resolve against the advertised PHP 8.0 floor. CI runs +the same gates on PHP 8.0, 8.4, and 8.5. + The coverage command runs the PHPUnit suite under PHPDBG and retains the focused 100% executable-line gate for selected HTTP hardening methods. It is not package-wide coverage. diff --git a/tests/coverage.php b/tests/coverage.php index 0a64553..0f7c473 100644 --- a/tests/coverage.php +++ b/tests/coverage.php @@ -1,6 +1,6 @@ run([ +$testExitCode = (new Command())->run([ 'phpunit', '--configuration', dirname(__DIR__) . '/phpunit.xml', '--do-not-cache-result', -]); +], false); $oplog = phpdbg_end_oplog(); $executable = phpdbg_get_executable(); From de476c9d342e0344eb379d328ad65881b03f0d7c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:41:33 +0600 Subject: [PATCH 24/38] fix(http): close review edge cases --- src/Http/Client/HttpClient.php | 26 +++++++++++++++++----- src/Http/Router/StaticRouter.php | 12 +++++++--- tests/Http/HttpClientTest.php | 35 ++++++++++++++++++++++++++++++ tests/Router/StaticRoutingTest.php | 28 ++++++++++++++++++++++++ tests/bootstrap.php | 2 +- 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index d13e013..8a67c60 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -14,12 +14,18 @@ final class HttpClient private $_body; + private bool $_hasBody = false; + private $_formParams = []; + private bool $_hasFormParams = false; + private $_multipart = []; private $_json = []; + private bool $_hasJson = false; + private $_queryParams = []; private $_params = []; @@ -152,7 +158,12 @@ public function setAllowedUnsafeHosts(array $hosts): self public function setBoundary($boundary): self { - $this->_boundary = '-------' . $this->validateMultipartMetadata($boundary); + $boundary = $this->validateMultipartMetadata($boundary); + if ($boundary === '' || \strlen($boundary) > 63 || preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D', $boundary) !== 1) { + throw new InvalidArgumentException('Invalid multipart boundary.'); + } + + $this->_boundary = '-------' . $boundary; if (!empty($this->_multipart)) { $this->_headers['Content-Type'] = ['multipart/form-data; boundary=' . $this->_boundary]; } @@ -163,7 +174,7 @@ public function setBoundary($boundary): self public function getBoundary(): string { if (!isset($this->_boundary)) { - $this->setBoundary(wp_generate_password(24)); + $this->setBoundary(wp_generate_password(24, false, false)); } return $this->_boundary; @@ -237,7 +248,8 @@ public function setQueryParam($key, $value): self public function setBody($body): self { - $this->_body = $body; + $this->_body = $body; + $this->_hasBody = true; return $this; } @@ -337,7 +349,8 @@ public function setDefault(array $config): void public function setJson($data): self { $this->setContentType('application/json'); - $this->_json = $data; + $this->_json = $data; + $this->_hasJson = true; return $this; } @@ -350,7 +363,8 @@ public function getJson() public function setFormParams($data): self { $this->setContentType('application/x-www-form-urlencoded'); - $this->_formParams = $data; + $this->_formParams = $data; + $this->_hasFormParams = true; return $this; } @@ -377,7 +391,7 @@ public function getPreparedPayload() { $payload = null; if (!empty($this->_multipart)) { - if (!empty($this->getBody()) || !empty($this->getFormParams()) || !empty($this->getJson())) { + if ($this->_hasBody || $this->_hasFormParams || $this->_hasJson) { throw new InvalidArgumentException('Do not use multipart with json, params or body'); } diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index c7b4b45..8c938fe 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -3,6 +3,7 @@ namespace BitApps\WPKit\Http\Router; use BitApps\WPKit\Http\RequestType; +use BitApps\WPKit\Http\Response; if (!\defined('ABSPATH')) { exit; @@ -68,13 +69,18 @@ public function handleRequest(): void } if ($this->isRouteMatched($route, $requestPath)) { - // this filter needs to be added here to avoid affecting other routes - add_filter('the_content', [$this, 'renderContent']); + $result = $route->handleRequest(); + if (Response::ERROR === Response::getStatus()) { + return; + } $this->content = (new Emitter\StaticResponseEmitter())->emit([ - 'data' => ['data' => $route->handleRequest()], + 'data' => ['data' => $result], ]); + // this filter needs to be added here to avoid affecting other routes + add_filter('the_content', [$this, 'renderContent']); + return; } } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 2bf9f2d..aee1b37 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -89,6 +89,15 @@ public function testHTTPClientMultipartRejectsHeaderInjectionInBoundary(): void ); } + public function testHTTPClientMultipartRejectsNonTokenBoundaryCharacters(): void + { + assertThrows( + InvalidArgumentException::class, + static fn () => (new HttpClient())->setBoundary('safe; charset=evil'), + 'multipart boundary accepted header delimiters', + ); + } + public function testHTTPClientMultipartRejectsHeaderInjectionInFieldName(): void { $client = (new HttpClient())->setMultipart([ @@ -119,6 +128,32 @@ public function testHTTPClientMultipartRejectsInjectedPartHeaders(): void ); } + public function testHTTPClientMultipartRejectsConfiguredStringZeroBody(): void + { + $client = (new HttpClient()) + ->setBody('0') + ->setMultipart([['name' => 'a', 'contents' => 'b']]); + + assertThrows( + InvalidArgumentException::class, + static fn () => $client->getPreparedPayload(), + 'multipart ignored a configured falsey body', + ); + } + + public function testHTTPClientMultipartRejectsConfiguredZeroJson(): void + { + $client = (new HttpClient()) + ->setJson(0) + ->setMultipart([['name' => 'a', 'contents' => 'b']]); + + assertThrows( + InvalidArgumentException::class, + static fn () => $client->getPreparedPayload(), + 'multipart ignored configured falsey JSON', + ); + } + public function testHTTPClientSafeRemoteRequestsAreTheDefault(): void { $client = new HttpClient(); diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index 907d96f..e277081 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -2,6 +2,7 @@ namespace BitApps\WPKit\Tests\Router; +use BitApps\WPKit\Http\Response; use BitApps\WPKit\Http\Router\RouteBase; use BitApps\WPKit\Http\Router\Router; use BitApps\WPKit\Http\Router\StaticRouter; @@ -10,6 +11,14 @@ use UnexpectedValueException; use WpKitTestState; +final class StaticDenyMiddleware +{ + public function handle() + { + return Response::error([])->code('DENIED')->message('Denied'); + } +} + /** * @internal * @@ -35,6 +44,25 @@ public function testStaticPostRouteDoesNotExecuteOnGet(): void assertSameValue(false, $called, 'POST static action executed for GET'); } + public function testDeniedStaticRouteDoesNotRenderErrorDataAsHtml(): void + { + $called = false; + $router = new Router('static', 'landing', null); + $router->setMiddlewares(['deny' => StaticDenyMiddleware::class]); + (new RouteBase())->middleware('deny')->get('protected', static function () use (&$called) { + $called = true; + + return 'secret'; + }); + new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); + $_SERVER['REQUEST_URI'] = '/landing/protected'; + + do_action('template_redirect'); + + assertSameValue(false, $called, 'denied static action executed'); + assertSameValue('page', apply_filters('the_content', 'page'), 'denial data was rendered as page HTML'); + } + public function testNullStaticOutputRendersAsEmptyContent(): void { $this->makeTransport(['empty' => static fn () => null]); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9d761e9..de6bc2b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -362,7 +362,7 @@ function wp_remote_retrieve_response_code($response) return $response['response']['code']; } -function wp_generate_password($length) +function wp_generate_password($length, $specialChars = true, $extraSpecialChars = false) { return str_repeat('a', $length); } From 221a5c46bc723b4e55c047291ec6a358f767f9df Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 8 Aug 2026 13:43:53 +0600 Subject: [PATCH 25/38] test: support private reflection on PHP 8 --- tests/Router/TransportRegistrationTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Router/TransportRegistrationTest.php b/tests/Router/TransportRegistrationTest.php index b283cd2..354096c 100644 --- a/tests/Router/TransportRegistrationTest.php +++ b/tests/Router/TransportRegistrationTest.php @@ -103,6 +103,7 @@ public function testStaticTransportRenderedRouteOutputIsAppendedToContent(): voi new Router('static', 'landing', null); $transport = new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); $reflection = new ReflectionProperty($transport, 'content'); + $reflection->setAccessible(true); $reflection->setValue($transport, '
route
'); assertSameValue( From bb5daee4ca225c46eaa51e63f2b31da5b4264b76 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 11:27:00 +0600 Subject: [PATCH 26/38] feat(container): lightweight IoC with autowiring Assisted-By: AI --- src/Container/Container.php | 188 ++++++++++++++++++ .../Exceptions/BindingResolutionException.php | 10 + .../Exceptions/ContainerException.php | 12 ++ tests/Container/ContainerTest.php | 96 +++++++++ 4 files changed, 306 insertions(+) create mode 100644 src/Container/Container.php create mode 100644 src/Container/Exceptions/BindingResolutionException.php create mode 100644 src/Container/Exceptions/ContainerException.php create mode 100644 tests/Container/ContainerTest.php diff --git a/src/Container/Container.php b/src/Container/Container.php new file mode 100644 index 0000000..4b06d11 --- /dev/null +++ b/src/Container/Container.php @@ -0,0 +1,188 @@ + + */ + protected $bindings = []; + + /** + * @var array + */ + protected $instances = []; + + /** + * @var array + */ + protected $aliases = []; + + /** + * @var array concretes currently being built, guards against circular dependencies + */ + protected $buildStack = []; + + /** + * Register a binding, optionally as a shared (singleton) instance. + * + * @param null|Closure|string $concrete + */ + public function bind(string $abstract, $concrete = null, bool $shared = false): void + { + $this->bindings[$abstract] = ['concrete' => $concrete ?? $abstract, 'shared' => $shared]; + } + + /** + * Register a binding that resolves to a single shared instance. + * + * @param null|Closure|string $concrete + */ + public function singleton(string $abstract, $concrete = null): void + { + $this->bind($abstract, $concrete, true); + } + + /** + * Register an existing object instance as a shared binding. + */ + public function instance(string $abstract, object $instance): object + { + return $this->instances[$abstract] = $instance; + } + + /** + * Register an alias for an abstract so it can be resolved under another name. + */ + public function alias(string $abstract, string $alias): void + { + $this->aliases[$alias] = $abstract; + } + + /** + * Check whether an abstract has an explicit binding or shared instance. + */ + public function bound(string $abstract): bool + { + $abstract = $this->aliases[$abstract] ?? $abstract; + + return isset($this->bindings[$abstract]) || isset($this->instances[$abstract]); + } + + /** + * Check whether an abstract is resolvable, either bound or an existing class. + */ + public function has(string $abstract): bool + { + return $this->bound($abstract) || class_exists($abstract); + } + + /** + * PSR-11-style alias for make(), resolving an entry by id. + * + * @return mixed + */ + public function get(string $id) + { + return $this->make($id); + } + + /** + * Resolve an abstract into a concrete instance, autowiring constructor dependencies as needed. + * + * @param array $parameters + * + * @return mixed + */ + public function make(string $abstract, array $parameters = []) + { + $abstract = $this->aliases[$abstract] ?? $abstract; + + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + + $binding = $this->bindings[$abstract] ?? null; + $concrete = $binding['concrete'] ?? $abstract; + + $object = $concrete instanceof Closure + ? $concrete($this, $parameters) + : $this->build($concrete, $parameters); + + if ($binding !== null && $binding['shared']) { + $this->instances[$abstract] = $object; + } + + return $object; + } + + /** + * Instantiate a concrete class via reflection, resolving constructor parameters recursively. + * + * @param array $parameters + * + * @return mixed + */ + protected function build(string $concrete, array $parameters = []) + { + // Re-entering a concrete already on the stack means it depends on itself, directly or transitively. + if (isset($this->buildStack[$concrete])) { + throw new BindingResolutionException("Circular dependency [{$concrete}]."); + } + + $this->buildStack[$concrete] = true; + + try { + $reflector = new ReflectionClass($concrete); + if (!$reflector->isInstantiable()) { + throw new BindingResolutionException("Target [{$concrete}] is not instantiable."); + } + + $constructor = $reflector->getConstructor(); + if ($constructor === null) { + return new $concrete(); + } + + $args = []; + foreach ($constructor->getParameters() as $param) { + $name = $param->getName(); + if (\array_key_exists($name, $parameters)) { + $args[] = $parameters[$name]; + + continue; + } + $type = $param->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $args[] = $this->make($type->getName()); + + continue; + } + if ($param->isDefaultValueAvailable()) { + $args[] = $param->getDefaultValue(); + + continue; + } + if ($type instanceof ReflectionNamedType && $type->allowsNull()) { + $args[] = null; + + continue; + } + + throw new BindingResolutionException("Unresolvable dependency [\${$name}] in class [{$concrete}]."); + } + + return $reflector->newInstanceArgs($args); + } finally { + unset($this->buildStack[$concrete]); + } + } +} diff --git a/src/Container/Exceptions/BindingResolutionException.php b/src/Container/Exceptions/BindingResolutionException.php new file mode 100644 index 0000000..9f82877 --- /dev/null +++ b/src/Container/Exceptions/BindingResolutionException.php @@ -0,0 +1,10 @@ +bind(WpKitGreeter::class, WpKitHello::class); + $this->assertNotSame($c->make(WpKitGreeter::class), $c->make(WpKitGreeter::class)); + } + + public function testSingletonReturnsSameInstance(): void + { + $c = new Container(); + $c->singleton(WpKitGreeter::class, WpKitHello::class); + $this->assertSame($c->make(WpKitGreeter::class), $c->make(WpKitGreeter::class)); + } + + public function testAutowiresConstructorByTypeHint(): void + { + $c = new Container(); + $c->bind(WpKitGreeter::class, WpKitHello::class); + $consumer = $c->make(WpKitConsumer::class); + $this->assertSame('hi', $consumer->g->greet()); + } + + public function testInstanceAndClosureBinding(): void + { + $c = new Container(); + $c->instance('flag', (object) ['x' => 1]); + $this->assertSame(1, $c->make('flag')->x); + $c->bind('made', fn (Container $app) => new WpKitHello()); + $this->assertInstanceOf(WpKitHello::class, $c->make('made')); + } + + public function testUnresolvableScalarThrows(): void + { + $c = new Container(); + $this->expectException(BindingResolutionException::class); + $c->make(NeedsScalar::class); + } + + public function testCircularDependencyThrows(): void + { + $c = new Container(); + $this->expectException(BindingResolutionException::class); + $c->make(WpKitCircularA::class); + } +} From 9cece4a8573b22939c5f83e6558a57ab8c07cb33 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 11:32:24 +0600 Subject: [PATCH 27/38] test(container): cover alias/bound/has public API Assisted-By: AI --- tests/Container/ContainerTest.php | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index a34d9a6..53bac6e 100644 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -93,4 +93,46 @@ public function testCircularDependencyThrows(): void $this->expectException(BindingResolutionException::class); $c->make(WpKitCircularA::class); } + + public function testAliasRedirectsResolveAndPresenceChecks(): void + { + $c = new Container(); + $c->singleton(WpKitGreeter::class, WpKitHello::class); + $c->alias(WpKitGreeter::class, 'a.alias'); + + $this->assertSame($c->make(WpKitGreeter::class), $c->make('a.alias')); + $this->assertTrue($c->bound('a.alias')); + $this->assertTrue($c->has('a.alias')); + } + + public function testBoundReflectsAllBindingKinds(): void + { + $c = new Container(); + $this->assertFalse($c->bound('missing')); + + $c->bind('bound.key', WpKitHello::class); + $this->assertTrue($c->bound('bound.key')); + + $c->singleton('singleton.key', WpKitHello::class); + $this->assertTrue($c->bound('singleton.key')); + + $c->instance('instance.key', new WpKitHello()); + $this->assertTrue($c->bound('instance.key')); + + $c->alias('bound.key', 'bound.alias'); + $this->assertTrue($c->bound('bound.alias')); + } + + public function testHasCoversBindingsClassExistsFallbackAndMissing(): void + { + $c = new Container(); + + $c->bind('has.key', WpKitHello::class); + $this->assertTrue($c->has('has.key')); + + // Unbound but existing class name resolves via the class_exists() fallback. + $this->assertTrue($c->has(WpKitHello::class)); + + $this->assertFalse($c->has('Bit\\Nonexistent\\Class')); + } } From d65f7682788474204db778c829476ca790f1f241 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 11:36:36 +0600 Subject: [PATCH 28/38] feat(container): Application + ServiceProvider register/boot Application extends Container to manage service provider lifecycle: register() accepts an instance or class-string, boot() runs each provider's boot() exactly once and is idempotent, providers registered after boot() run immediately. Assisted-By: AI --- src/Container/Application.php | 55 +++++++++++++++++++ src/Container/ServiceProvider.php | 28 ++++++++++ tests/Container/ApplicationTest.php | 82 +++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/Container/Application.php create mode 100644 src/Container/ServiceProvider.php create mode 100644 tests/Container/ApplicationTest.php diff --git a/src/Container/Application.php b/src/Container/Application.php new file mode 100644 index 0000000..2e5ea88 --- /dev/null +++ b/src/Container/Application.php @@ -0,0 +1,55 @@ +|ServiceProvider $provider + */ + public function register($provider): void + { + if (\is_string($provider)) { + $provider = new $provider($this); + } + $this->providers[] = $provider; + $provider->register(); + if ($this->booted) { + $provider->boot(); + } + } + + /** + * Boot all registered providers exactly once; subsequent calls are no-ops. + */ + public function boot(): void + { + if ($this->booted) { + return; + } + foreach ($this->providers as $provider) { + $provider->boot(); + } + $this->booted = true; + } + + /** + * Report whether boot() has already run. + */ + public function booted(): bool + { + return $this->booted; + } +} diff --git a/src/Container/ServiceProvider.php b/src/Container/ServiceProvider.php new file mode 100644 index 0000000..9c88b9b --- /dev/null +++ b/src/Container/ServiceProvider.php @@ -0,0 +1,28 @@ +app = $app; + } + + /** + * Register bindings on the container. + */ + abstract public function register(): void; + + /** + * Run after all providers are registered; override to perform post-registration setup. + */ + public function boot(): void + { + } +} diff --git a/tests/Container/ApplicationTest.php b/tests/Container/ApplicationTest.php new file mode 100644 index 0000000..cbd7c0f --- /dev/null +++ b/tests/Container/ApplicationTest.php @@ -0,0 +1,82 @@ +register(RecordingProvider::class); + $app->boot(); + $app->boot(); + $this->assertSame(['register', 'boot'], RecordingProvider::$log); + $this->assertTrue($app->booted()); + $this->assertSame('ok', $app->make('recorded')); + } + + public function testRegisterAcceptsAlreadyConstructedProviderInstance(): void + { + RecordingProvider::$log = []; + + $app = new Application(); + $provider = new RecordingProvider($app); + $app->register($provider); + $app->boot(); + + $this->assertSame(['register', 'boot'], RecordingProvider::$log); + } + + public function testRegisterAfterBootRunsThatProvidersBootImmediately(): void + { + RecordingProvider::$log = []; + LateProvider::$booted = false; + + $app = new Application(); + $app->register(RecordingProvider::class); + $app->boot(); + + $app->register(LateProvider::class); + + $this->assertTrue(LateProvider::$booted); + } +} + +class RecordingProvider extends ServiceProvider +{ + public static array $log = []; + + public function register(): void + { + self::$log = ['register']; + // Container::instance() permanently shadows bind() for the same key (make() checks + // instances first, by design — see ContainerTest::testInstanceAndClosureBinding), so + // only bind() is exercised here to keep the 'ok' resolution meaningful. + $this->app->bind('recorded', fn () => 'ok'); + } + + public function boot(): void + { + self::$log[] = 'boot'; + } +} + +class LateProvider extends ServiceProvider +{ + public static bool $booted = false; + + public function register(): void + { + } + + public function boot(): void + { + self::$booted = true; + } +} From a1bedc494904c9b690a0a0bd8288b274c767f809 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 11:43:35 +0600 Subject: [PATCH 29/38] feat(settings): schema + typed fields Assisted-By: AI --- src/Settings/SettingField.php | 159 ++++++++++++++++++++++++++ src/Settings/SettingsSchema.php | 85 ++++++++++++++ tests/Settings/SettingsSchemaTest.php | 29 +++++ 3 files changed, 273 insertions(+) create mode 100644 src/Settings/SettingField.php create mode 100644 src/Settings/SettingsSchema.php create mode 100644 tests/Settings/SettingsSchemaTest.php diff --git a/src/Settings/SettingField.php b/src/Settings/SettingField.php new file mode 100644 index 0000000..5c704cc --- /dev/null +++ b/src/Settings/SettingField.php @@ -0,0 +1,159 @@ +sanitizer = $sanitizer; + } + + /** + * Define a boolean setting. + * + * @param mixed $default + */ + public static function bool(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self + { + return new self($key, self::TYPE_BOOL, $default, $group, null, $sanitizer); + } + + /** + * Define an integer setting. + * + * @param mixed $default + */ + public static function int(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self + { + return new self($key, self::TYPE_INT, $default, $group, null, $sanitizer); + } + + /** + * Define a string setting. + * + * @param mixed $default + */ + public static function string(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self + { + return new self($key, self::TYPE_STRING, $default, $group, null, $sanitizer); + } + + /** + * Define a float setting. + * + * @param mixed $default + */ + public static function float(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self + { + return new self($key, self::TYPE_FLOAT, $default, $group, null, $sanitizer); + } + + /** + * Define an array setting. + * + * @param mixed $default + */ + public static function arr(string $key, $default, ?string $group = null, ?callable $sanitizer = null): self + { + return new self($key, self::TYPE_ARRAY, $default, $group, null, $sanitizer); + } + + /** + * Define an enum setting restricted to a fixed list of choices. + * + * @param mixed $default + */ + public static function enum(string $key, array $choices, $default, ?string $group = null, ?callable $sanitizer = null): self + { + return new self($key, self::TYPE_ENUM, $default, $group, $choices, $sanitizer); + } + + public function key(): string + { + return $this->key; + } + + public function type(): string + { + return $this->type; + } + + /** + * @return mixed + */ + public function default() + { + return $this->default; + } + + public function group(): ?string + { + return $this->group; + } + + public function choices(): ?array + { + return $this->choices; + } + + /** + * Coerce a raw value to this field's type, then apply the sanitizer if one was given. + * + * @param mixed $value + * + * @return mixed + */ + public function cast($value) + { + $cast = $this->castByType($value); + + return $this->sanitizer !== null ? ($this->sanitizer)($cast) : $cast; + } + + /** + * @param mixed $value + * + * @return mixed + */ + private function castByType($value) + { + return match ($this->type) { + self::TYPE_BOOL => filter_var($value, \FILTER_VALIDATE_BOOLEAN), + self::TYPE_INT => (int) $value, + self::TYPE_FLOAT => (float) $value, + self::TYPE_ARRAY => (array) $value, + self::TYPE_ENUM => \in_array($value, $this->choices ?? [], true) ? $value : $this->default, + default => $value, + }; + } +} diff --git a/src/Settings/SettingsSchema.php b/src/Settings/SettingsSchema.php new file mode 100644 index 0000000..2b06995 --- /dev/null +++ b/src/Settings/SettingsSchema.php @@ -0,0 +1,85 @@ + + */ + private array $fields = []; + + /** + * Register one or more fields, preserving insertion order. + */ + public function add(SettingField ...$fields): self + { + foreach ($fields as $field) { + $this->fields[$field->key()] = $field; + } + + return $this; + } + + /** + * Check whether a field is registered for the given key. + */ + public function has(string $key): bool + { + return isset($this->fields[$key]); + } + + /** + * Fetch the field registered for the given key, or null if none. + */ + public function field(string $key): ?SettingField + { + return $this->fields[$key] ?? null; + } + + /** + * All registered fields, keyed by field key, in insertion order. + * + * @return array + */ + public function fields(): array + { + return $this->fields; + } + + /** + * Map of field key to its default value. + * + * @return array + */ + public function defaults(): array + { + $defaults = []; + foreach ($this->fields as $key => $field) { + $defaults[$key] = $field->default(); + } + + return $defaults; + } + + /** + * Unique field groups, in the order they were first seen. + * + * @return array + */ + public function groups(): array + { + $groups = []; + foreach ($this->fields as $field) { + $group = $field->group(); + if ($group !== null && !\in_array($group, $groups, true)) { + $groups[] = $group; + } + } + + return $groups; + } +} diff --git a/tests/Settings/SettingsSchemaTest.php b/tests/Settings/SettingsSchemaTest.php new file mode 100644 index 0000000..40acc2c --- /dev/null +++ b/tests/Settings/SettingsSchemaTest.php @@ -0,0 +1,29 @@ +add( + SettingField::bool('logging_enabled', true, 'general'), + SettingField::int('retention', 30, 'general'), + SettingField::enum('mode', ['full', 'redacted'], 'full', 'privacy') + ); + $this->assertSame(['logging_enabled' => true, 'retention' => 30, 'mode' => 'full'], $schema->defaults()); + $this->assertTrue($schema->field('logging_enabled')->cast('1')); + $this->assertSame(30, $schema->field('retention')->cast('30')); + $this->assertSame(['general', 'privacy'], $schema->groups()); + } + + public function testEnumRejectsInvalid(): void + { + $field = SettingField::enum('mode', ['full', 'redacted'], 'full'); + $this->assertSame('full', $field->cast('nope')); // invalid → default + } +} From a3fb5775faa7e2953684a6ddecaefab2522e0d34 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 11:50:05 +0600 Subject: [PATCH 30/38] test(settings): cover cast() sanitizer; docblock getters Assisted-By: AI --- src/Settings/SettingField.php | 16 ++++++++++++++++ tests/Settings/SettingsSchemaTest.php | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Settings/SettingField.php b/src/Settings/SettingField.php index 5c704cc..b26984f 100644 --- a/src/Settings/SettingField.php +++ b/src/Settings/SettingField.php @@ -98,17 +98,25 @@ public static function enum(string $key, array $choices, $default, ?string $grou return new self($key, self::TYPE_ENUM, $default, $group, $choices, $sanitizer); } + /** + * Return the field's unique key. + */ public function key(): string { return $this->key; } + /** + * Return the field's value type (one of the TYPE_* constants). + */ public function type(): string { return $this->type; } /** + * Return the field's default value. + * * @return mixed */ public function default() @@ -116,11 +124,17 @@ public function default() return $this->default; } + /** + * Return the field's group, or null if it belongs to none. + */ public function group(): ?string { return $this->group; } + /** + * Return the enum field's allowed choices, or null for non-enum fields. + */ public function choices(): ?array { return $this->choices; @@ -141,6 +155,8 @@ public function cast($value) } /** + * Coerce a raw value to this field's type, without applying the sanitizer. + * * @param mixed $value * * @return mixed diff --git a/tests/Settings/SettingsSchemaTest.php b/tests/Settings/SettingsSchemaTest.php index 40acc2c..99b2017 100644 --- a/tests/Settings/SettingsSchemaTest.php +++ b/tests/Settings/SettingsSchemaTest.php @@ -26,4 +26,17 @@ public function testEnumRejectsInvalid(): void $field = SettingField::enum('mode', ['full', 'redacted'], 'full'); $this->assertSame('full', $field->cast('nope')); // invalid → default } + + public function testSanitizerRunsAfterTypeCoercion(): void + { + // '5' is int-cast to 5, then the sanitizer adds 1 → proves coerce-then-sanitize order. + $field = SettingField::int('x', 0, null, static fn ($v) => $v + 1); + $this->assertSame(6, $field->cast('5')); + } + + public function testSanitizerAppliesToStringField(): void + { + $field = SettingField::string('name', '', null, static fn ($v) => trim($v)); + $this->assertSame('bob', $field->cast(' bob ')); + } } From 91ec6b84b346b742596c5fa5e4efdd9de75d5f2a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 11:53:57 +0600 Subject: [PATCH 31/38] feat(settings): repository with persistence Assisted-By: AI --- src/Settings/SettingsRepository.php | 110 ++++++++++++++++++++++ tests/Settings/SettingsRepositoryTest.php | 44 +++++++++ tests/bootstrap.php | 11 ++- 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/Settings/SettingsRepository.php create mode 100644 tests/Settings/SettingsRepositoryTest.php diff --git a/src/Settings/SettingsRepository.php b/src/Settings/SettingsRepository.php new file mode 100644 index 0000000..ecc7955 --- /dev/null +++ b/src/Settings/SettingsRepository.php @@ -0,0 +1,110 @@ + + */ + private array $values = []; + + public function __construct( + private string $optionName, + private SettingsSchema $schema, + private bool $autoload = true + ) { + $this->reload(); + } + + /** + * Return the cast value for a key, or its field/explicit default if unset. + * + * @param mixed $default + * + * @return mixed + */ + public function get(string $key, $default = null) + { + if (\array_key_exists($key, $this->values)) { + return $this->values[$key]; + } + + $field = $this->schema->field($key); + + return $field !== null ? $field->default() : $default; + } + + /** + * Cast and store a value for a known key; throws for keys not in the schema. + * + * @param mixed $value + */ + public function set(string $key, $value): self + { + $field = $this->schema->field($key); + + if ($field === null) { + throw new InvalidArgumentException("Unknown setting key: {$key}"); + } + + $this->values[$key] = $field->cast($value); + + return $this; + } + + /** + * Set multiple values at once. + * + * @param array $values + */ + public function fill(array $values): self + { + foreach ($values as $key => $value) { + $this->set($key, $value); + } + + return $this; + } + + /** + * All current values, keyed by field key. + * + * @return array + */ + public function all(): array + { + return $this->values; + } + + /** + * Check whether a key is registered in the schema. + */ + public function has(string $key): bool + { + return $this->schema->has($key); + } + + /** + * Persist the current values to the wp_options row. + */ + public function save(): bool + { + return update_option($this->optionName, $this->values, $this->autoload ? 'yes' : 'no'); + } + + /** + * Re-read the wp_options row, merging stored values over schema defaults. + */ + public function reload(): void + { + $stored = (array) get_option($this->optionName, []); + + $this->values = array_merge($this->schema->defaults(), array_intersect_key($stored, $this->schema->fields())); + } +} diff --git a/tests/Settings/SettingsRepositoryTest.php b/tests/Settings/SettingsRepositoryTest.php new file mode 100644 index 0000000..0b210d6 --- /dev/null +++ b/tests/Settings/SettingsRepositoryTest.php @@ -0,0 +1,44 @@ +add( + SettingField::bool('logging_enabled', true), + SettingField::int('retention', 30) + ); + + return new SettingsRepository('demo_settings', $schema); + } + + public function testReturnsDefaultWhenUnset(): void + { + $this->assertSame(30, $this->repo()->get('retention')); + } + + public function testSetSaveReload(): void + { + $repo = $this->repo(); + $repo->set('retention', '45')->set('logging_enabled', '0')->save(); + $this->assertSame(45, WpKitTestState::$options['demo_settings']['retention']); + $fresh = $this->repo(); + $this->assertSame(45, $fresh->get('retention')); + $this->assertFalse($fresh->get('logging_enabled')); + } + + public function testUnknownKeyRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->repo()->set('nope', 1); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index de6bc2b..c28d251 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -461,9 +461,16 @@ function flush_rewrite_rules() ++WpKitTestState::$rewriteFlushes; } -function get_option($name) +function get_option($name, $default = false) { - return WpKitTestState::$options[$name] ?? false; + return WpKitTestState::$options[$name] ?? $default; +} + +function update_option($name, $value, $autoload = null) +{ + WpKitTestState::$options[$name] = $value; + + return true; } function current_time($type) From d032dda4c4adafdae9fec345089e7f7b6c376f76 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 12:03:07 +0600 Subject: [PATCH 32/38] feat(cron): scheduler with custom schedules Add BitApps\WPKit\Cron\Scheduler: registers custom cron_schedules entries, recurring jobs (job()) and one-off jobs (once()), and wires them onto WordPress cron via boot(). boot() guards re-entry with a protected $booted flag so a second call does not re-register action callbacks or re-schedule events, since the test double's add_action appends unconditionally instead of deduping like WordPress core. Extend tests/bootstrap.php with WpKitTestState::$cron plus wp_next_scheduled/wp_schedule_event/wp_schedule_single_event/ wp_clear_scheduled_hook stubs, reset in resetWpKitTestState() so scheduled-event state doesn't leak across tests. Assisted-By: AI --- src/Cron/Scheduler.php | 108 +++++++++++++++++++++++++++++++++++ tests/Cron/SchedulerTest.php | 67 ++++++++++++++++++++++ tests/bootstrap.php | 29 ++++++++++ 3 files changed, 204 insertions(+) create mode 100644 src/Cron/Scheduler.php create mode 100644 tests/Cron/SchedulerTest.php diff --git a/src/Cron/Scheduler.php b/src/Cron/Scheduler.php new file mode 100644 index 0000000..d6194c5 --- /dev/null +++ b/src/Cron/Scheduler.php @@ -0,0 +1,108 @@ + + */ + private array $schedules = []; + + /** + * @var array + */ + private array $recurringJobs = []; + + /** + * @var array + */ + private array $onceJobs = []; + + /** + * Register a custom interval, merged into WordPress's cron_schedules filter on boot. + */ + public function addSchedule(string $name, int $intervalSeconds, string $display): self + { + $this->schedules[$name] = ['interval' => $intervalSeconds, 'display' => $display]; + + return $this; + } + + /** + * Register a recurring job that fires on the given hook at the given recurrence. + */ + public function job(string $hook, string $recurrence, callable $callback, array $args = []): self + { + $this->recurringJobs[$hook] = compact('recurrence', 'callback', 'args'); + + return $this; + } + + /** + * Register a one-off job that fires once at the given timestamp. + */ + public function once(string $hook, int $timestamp, callable $callback, array $args = []): self + { + $this->onceJobs[] = compact('hook', 'timestamp', 'callback', 'args'); + + return $this; + } + + /** + * Wire the cron_schedules filter, register job callbacks, and schedule pending events; safe to call repeatedly. + */ + public function boot(): void + { + if ($this->booted) { + return; + } + + $this->booted = true; + + add_filter('cron_schedules', fn (array $schedules): array => array_merge($schedules, $this->schedules)); + + foreach ($this->recurringJobs as $hook => $job) { + add_action($hook, $job['callback'], 10, \count($job['args'])); + + if (!wp_next_scheduled($hook)) { + wp_schedule_event(time(), $job['recurrence'], $hook, $job['args']); + } + } + + foreach ($this->onceJobs as $job) { + add_action($job['hook'], $job['callback'], 10, \count($job['args'])); + + if (!wp_next_scheduled($job['hook'])) { + wp_schedule_single_event($job['timestamp'], $job['hook'], $job['args']); + } + } + } + + /** + * Clear the scheduled event for a single hook. + */ + public function unschedule(string $hook): void + { + wp_clear_scheduled_hook($hook); + } + + /** + * Clear scheduled events for every registered recurring and one-off job. + */ + public function clearAll(): void + { + foreach (array_keys($this->recurringJobs) as $hook) { + $this->unschedule($hook); + } + + foreach ($this->onceJobs as $job) { + $this->unschedule($job['hook']); + } + } +} diff --git a/tests/Cron/SchedulerTest.php b/tests/Cron/SchedulerTest.php new file mode 100644 index 0000000..7185727 --- /dev/null +++ b/tests/Cron/SchedulerTest.php @@ -0,0 +1,67 @@ +job('demo_gc', 'daily', function () {}); + $s->boot(); + $s->boot(); // idempotent + + $this->assertArrayHasKey('demo_gc', WpKitTestState::$cron); + // WpKitTestState::$actions is keyed [hook => [ {callback,priority,acceptedArgs}, ... ]]. + // Assert the hook has exactly one registered callback despite two boot() calls. + $this->assertArrayHasKey('demo_gc', WpKitTestState::$actions); + $this->assertCount(1, WpKitTestState::$actions['demo_gc']); + } + + public function testCustomScheduleRegistered(): void + { + $s = (new Scheduler())->addSchedule('every_five', 300, 'Every Five'); + $s->boot(); + + $schedules = apply_filters('cron_schedules', []); + + $this->assertSame(300, $schedules['every_five']['interval']); + } + + public function testClearAllUnschedules(): void + { + $s = new Scheduler(); + $s->job('demo_gc', 'daily', function () {}); + $s->boot(); + $s->clearAll(); + + $this->assertArrayNotHasKey('demo_gc', WpKitTestState::$cron); + } + + public function testOnceSchedulesSingleEventAndGuardsReentry(): void + { + $s = new Scheduler(); + $s->once('demo_once', 1700000000, function () {}); + $s->boot(); + $s->boot(); // idempotent + + $this->assertSame(1700000000, WpKitTestState::$cron['demo_once']); + $this->assertCount(1, WpKitTestState::$actions['demo_once']); + } + + public function testUnscheduleClearsSingleHookOnly(): void + { + $s = new Scheduler(); + $s->job('demo_gc', 'daily', function () {}); + $s->job('demo_other', 'hourly', function () {}); + $s->boot(); + $s->unschedule('demo_gc'); + + $this->assertArrayNotHasKey('demo_gc', WpKitTestState::$cron); + $this->assertArrayHasKey('demo_other', WpKitTestState::$cron); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c28d251..0e9117d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -25,6 +25,8 @@ final class WpKitTestState public static $filters = []; + public static $cron = []; + public static $restRoutes = []; public static $shortcodes = []; @@ -194,6 +196,7 @@ function resetWpKitTestState() WpKitTestState::$lastHttpRequest = null; WpKitTestState::$actions = []; WpKitTestState::$filters = []; + WpKitTestState::$cron = []; WpKitTestState::$restRoutes = []; WpKitTestState::$shortcodes = []; WpKitTestState::$shortcodeRenders = []; @@ -432,6 +435,32 @@ function apply_filters($tag, $value, ...$args) return $value; } +function wp_next_scheduled($hook, $args = []) +{ + return WpKitTestState::$cron[$hook] ?? false; +} + +function wp_schedule_event($timestamp, $recurrence, $hook, $args = []) +{ + WpKitTestState::$cron[$hook] = $timestamp; + + return true; +} + +function wp_schedule_single_event($timestamp, $hook, $args = []) +{ + WpKitTestState::$cron[$hook] = $timestamp; + + return true; +} + +function wp_clear_scheduled_hook($hook, $args = []) +{ + unset(WpKitTestState::$cron[$hook]); + + return null; +} + function register_rest_route($namespace, $route, $args) { WpKitTestState::$restRoutes[] = compact('namespace', 'route', 'args'); From 5b0b3b00b7070a3c5be445d1bd748152ea3e10a2 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 12:10:41 +0600 Subject: [PATCH 33/38] feat(cache): store contract + ArrayStore + Repository Assisted-By: AI --- src/Cache/Contracts/Store.php | 61 ++++++++++++ src/Cache/Repository.php | 162 ++++++++++++++++++++++++++++++++ src/Cache/Stores/ArrayStore.php | 127 +++++++++++++++++++++++++ tests/Cache/RepositoryTest.php | 89 ++++++++++++++++++ 4 files changed, 439 insertions(+) create mode 100644 src/Cache/Contracts/Store.php create mode 100644 src/Cache/Repository.php create mode 100644 src/Cache/Stores/ArrayStore.php create mode 100644 tests/Cache/RepositoryTest.php diff --git a/src/Cache/Contracts/Store.php b/src/Cache/Contracts/Store.php new file mode 100644 index 0000000..2600c7e --- /dev/null +++ b/src/Cache/Contracts/Store.php @@ -0,0 +1,61 @@ +store = $store; + } + + /** + * Retrieve an item, or the given default if it is missing or expired. + * + * @param mixed $default + * + * @return mixed + */ + public function get(string $key, $default = null) + { + $value = $this->store->get($key); + + return $value !== null ? $value : $default; + } + + /** + * Check whether a non-expired item exists for the key. + */ + public function has(string $key): bool + { + return $this->get($key) !== null; + } + + /** + * Store an item for the given number of seconds. + * + * @param mixed $value + */ + public function put(string $key, $value, int $ttl): bool + { + return $this->store->put($key, $value, $ttl); + } + + /** + * Store an item only if it doesn't already exist (or has expired). + * + * @param mixed $value + */ + public function add(string $key, $value, int $ttl): bool + { + return $this->store->add($key, $value, $ttl); + } + + /** + * Store an item indefinitely. + * + * @param mixed $value + */ + public function forever(string $key, $value): bool + { + return $this->store->forever($key, $value); + } + + /** + * Remove an item from the store. + */ + public function forget(string $key): bool + { + return $this->store->forget($key); + } + + /** + * Remove all items from the store. + */ + public function flush(): bool + { + return $this->store->flush(); + } + + /** + * Increment a stored integer value and return the new value. + * + * @return int|false + */ + public function increment(string $key, int $by = 1) + { + return $this->store->increment($key, $by); + } + + /** + * Decrement a stored integer value and return the new value. + * + * @return int|false + */ + public function decrement(string $key, int $by = 1) + { + return $this->store->decrement($key, $by); + } + + /** + * Return the cached value for a key, computing and storing it via the callback on a miss. + * + * @return mixed + */ + public function remember(string $key, int $ttl, callable $callback) + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + + $this->put($key, $value, $ttl); + + return $value; + } + + /** + * Return the cached value for a key, computing and storing it forever via the callback on a miss. + * + * @return mixed + */ + public function rememberForever(string $key, callable $callback) + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + + $this->forever($key, $value); + + return $value; + } + + /** + * Retrieve an item and remove it from the store in one step. + * + * @param mixed $default + * + * @return mixed + */ + public function pull(string $key, $default = null) + { + $value = $this->get($key, $default); + + $this->forget($key); + + return $value; + } +} diff --git a/src/Cache/Stores/ArrayStore.php b/src/Cache/Stores/ArrayStore.php new file mode 100644 index 0000000..04a78d2 --- /dev/null +++ b/src/Cache/Stores/ArrayStore.php @@ -0,0 +1,127 @@ + + */ + private array $items = []; + + /** + * @var callable + */ + private $clock; + + /** + * @param null|callable $clock returns the current unix timestamp; defaults to time() but is + * injectable so tests can advance it deterministically (see M4) + */ + public function __construct(?callable $clock = null) + { + $this->clock = $clock ?? 'time'; + } + + /** + * @inheritDoc + */ + public function get(string $key) + { + if (!isset($this->items[$key])) { + return; + } + + $item = $this->items[$key]; + + if ($item['expiresAt'] !== null && ($this->clock)() >= $item['expiresAt']) { + $this->forget($key); + + return; + } + + return $item['value']; + } + + /** + * @inheritDoc + */ + public function put(string $key, $value, int $ttl): bool + { + $this->items[$key] = [ + 'value' => $value, + 'expiresAt' => ($this->clock)() + $ttl, + ]; + + return true; + } + + /** + * @inheritDoc + */ + public function add(string $key, $value, int $ttl): bool + { + if ($this->get($key) !== null) { + return false; + } + + return $this->put($key, $value, $ttl); + } + + /** + * @inheritDoc + */ + public function forever(string $key, $value): bool + { + $this->items[$key] = ['value' => $value, 'expiresAt' => null]; + + return true; + } + + /** + * @inheritDoc + */ + public function forget(string $key): bool + { + unset($this->items[$key]); + + return true; + } + + /** + * @inheritDoc + */ + public function flush(): bool + { + $this->items = []; + + return true; + } + + /** + * @inheritDoc + */ + public function increment(string $key, int $by = 1) + { + $new = (int) $this->get($key) + $by; + + // Preserve an existing expiry; a freshly-created counter never expires, matching forever() semantics. + $this->items[$key]['value'] = $new; + $this->items[$key]['expiresAt'] ??= null; + + return $new; + } + + /** + * @inheritDoc + */ + public function decrement(string $key, int $by = 1) + { + return $this->increment($key, -$by); + } +} diff --git a/tests/Cache/RepositoryTest.php b/tests/Cache/RepositoryTest.php new file mode 100644 index 0000000..a0dc0f3 --- /dev/null +++ b/tests/Cache/RepositoryTest.php @@ -0,0 +1,89 @@ +assertSame('value', $repo->remember('k', 60, $make)); + $this->assertSame('value', $repo->remember('k', 60, $make)); + $this->assertSame(1, $calls); + } + + public function testPullReturnsAndForgets(): void + { + $repo = new Repository(new ArrayStore()); + $repo->put('k', 'v', 60); + $this->assertSame('v', $repo->pull('k')); + $this->assertNull($repo->get('k')); + } + + public function testIncrement(): void + { + $repo = new Repository(new ArrayStore()); + $repo->put('n', 1, 60); + $this->assertSame(3, $repo->increment('n', 2)); + } + + public function testGetReturnsDefaultWhenMissing(): void + { + $repo = new Repository(new ArrayStore()); + + $this->assertSame('fallback', $repo->get('missing', 'fallback')); + } + + public function testHasReflectsPresence(): void + { + $repo = new Repository(new ArrayStore()); + + $this->assertFalse($repo->has('k')); + $repo->put('k', 'v', 60); + $this->assertTrue($repo->has('k')); + } + + public function testRememberForeverComputesOnceThenCaches(): void + { + $repo = new Repository(new ArrayStore()); + $calls = 0; + $make = function () use (&$calls) { + $calls++; + + return 'value'; + }; + $this->assertSame('value', $repo->rememberForever('k', $make)); + $this->assertSame('value', $repo->rememberForever('k', $make)); + $this->assertSame(1, $calls); + } + + /** + * Regression for M4: expiry must be computed from an injectable clock, not the + * global time(), so tests can deterministically advance past a TTL. + */ + public function testEntryExpiresWhenClockAdvancesPastTtl(): void + { + $now = 1000; + $clock = function () use (&$now) { + return $now; + }; + $repo = new Repository(new ArrayStore($clock)); + + $repo->put('k', 'v', 60); + $this->assertSame('v', $repo->get('k')); + + $now += 61; + + $this->assertNull($repo->get('k')); + } +} From a7134bd53fed423944dd3502573e9076ca9a0f06 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 12:19:03 +0600 Subject: [PATCH 34/38] feat(cache): transient, object-cache, file stores Adds three concrete Store implementations backed by WordPress transients, the WP object cache, and the filesystem, plus matching get_transient/set_transient/delete_transient and wp_cache_* bootstrap stubs (WpKitTestState::$transients / $objectCache, reset in resetWpKitTestState()) so they're testable without WordPress. Assisted-By: AI --- src/Cache/Stores/FileStore.php | 173 +++++++++++++++++++ src/Cache/Stores/TransientStore.php | 98 +++++++++++ src/Cache/Stores/WpObjectCacheStore.php | 93 ++++++++++ tests/Cache/StoresTest.php | 217 ++++++++++++++++++++++++ tests/bootstrap.php | 90 ++++++++++ 5 files changed, 671 insertions(+) create mode 100644 src/Cache/Stores/FileStore.php create mode 100644 src/Cache/Stores/TransientStore.php create mode 100644 src/Cache/Stores/WpObjectCacheStore.php create mode 100644 tests/Cache/StoresTest.php diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php new file mode 100644 index 0000000..21ce832 --- /dev/null +++ b/src/Cache/Stores/FileStore.php @@ -0,0 +1,173 @@ +directory = rtrim($directory, '/\\'); + $this->prefix = $prefix; + $this->clock = $clock ?? 'time'; + + if (!is_dir($this->directory)) { + mkdir($this->directory, 0755, true); + } + } + + /** + * @inheritDoc + */ + public function get(string $key) + { + $entry = $this->read($key); + + return $entry === null ? null : $entry['value']; + } + + /** + * @inheritDoc + */ + public function put(string $key, $value, int $ttl): bool + { + return $this->write($key, $value, ($this->clock)() + $ttl); + } + + /** + * @inheritDoc + */ + public function add(string $key, $value, int $ttl): bool + { + if ($this->get($key) !== null) { + return false; + } + + return $this->put($key, $value, $ttl); + } + + /** + * @inheritDoc + */ + public function forever(string $key, $value): bool + { + return $this->write($key, $value, null); + } + + /** + * @inheritDoc + */ + public function forget(string $key): bool + { + $path = $this->path($key); + + if (!is_file($path)) { + return false; + } + + return unlink($path); + } + + /** + * @inheritDoc + */ + public function flush(): bool + { + foreach (glob($this->directory . '/*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function increment(string $key, int $by = 1) + { + $entry = $this->read($key); + $new = (int) ($entry['value'] ?? 0) + $by; + + // Preserve an existing expiry; a freshly-created counter never expires, matching forever() semantics. + $this->write($key, $new, $entry['expiresAt'] ?? null); + + return $new; + } + + /** + * @inheritDoc + */ + public function decrement(string $key, int $by = 1) + { + return $this->increment($key, -$by); + } + + private function path(string $key): string + { + return $this->directory . '/' . sha1($this->prefix . $key); + } + + /** + * Reads and decodes the entry file for a key, evicting and returning null if it has expired. + * + * @return null|array{expiresAt:null|int,value:mixed} + */ + private function read(string $key): ?array + { + $path = $this->path($key); + + if (!is_file($path)) { + return null; + } + + $entry = @unserialize(file_get_contents($path)); + + if (!\is_array($entry) || !\array_key_exists('value', $entry) || !\array_key_exists('expiresAt', $entry)) { + return null; + } + + if ($entry['expiresAt'] !== null && ($this->clock)() >= $entry['expiresAt']) { + $this->forget($key); + + return null; + } + + return $entry; + } + + /** + * Atomically writes an entry via a temp file + rename, avoiding partial reads by concurrent processes. + * + * @param mixed $value + */ + private function write(string $key, $value, ?int $expiresAt): bool + { + $path = $this->path($key); + $tmp = $path . '.' . uniqid('', true) . '.tmp'; + + if (file_put_contents($tmp, serialize(['expiresAt' => $expiresAt, 'value' => $value])) === false) { + return false; + } + + return rename($tmp, $path); + } +} diff --git a/src/Cache/Stores/TransientStore.php b/src/Cache/Stores/TransientStore.php new file mode 100644 index 0000000..fb43b95 --- /dev/null +++ b/src/Cache/Stores/TransientStore.php @@ -0,0 +1,98 @@ +prefix = $prefix; + } + + /** + * @inheritDoc + */ + public function get(string $key) + { + $value = get_transient($this->prefix . $key); + + return $value === false ? null : $value; + } + + /** + * @inheritDoc + */ + public function put(string $key, $value, int $ttl): bool + { + return set_transient($this->prefix . $key, $value, $ttl); + } + + /** + * @inheritDoc + */ + public function add(string $key, $value, int $ttl): bool + { + if ($this->get($key) !== null) { + return false; + } + + return $this->put($key, $value, $ttl); + } + + /** + * @inheritDoc + */ + public function forever(string $key, $value): bool + { + return $this->put($key, $value, 0); + } + + /** + * @inheritDoc + */ + public function forget(string $key): bool + { + return delete_transient($this->prefix . $key); + } + + /** + * @inheritDoc + */ + public function flush(): bool + { + return false; + } + + /** + * @inheritDoc + */ + public function increment(string $key, int $by = 1) + { + $new = (int) $this->get($key) + $by; + + // get_transient() doesn't expose the remaining TTL, so a counter can't preserve its + // original expiry here; it becomes non-expiring, matching forever() semantics. + $this->forever($key, $new); + + return $new; + } + + /** + * @inheritDoc + */ + public function decrement(string $key, int $by = 1) + { + return $this->increment($key, -$by); + } +} diff --git a/src/Cache/Stores/WpObjectCacheStore.php b/src/Cache/Stores/WpObjectCacheStore.php new file mode 100644 index 0000000..ab2c13e --- /dev/null +++ b/src/Cache/Stores/WpObjectCacheStore.php @@ -0,0 +1,93 @@ +group = $group; + } + + /** + * @inheritDoc + */ + public function get(string $key) + { + $found = false; + $value = wp_cache_get($key, $this->group, false, $found); + + return $found ? $value : null; + } + + /** + * @inheritDoc + */ + public function put(string $key, $value, int $ttl): bool + { + return wp_cache_set($key, $value, $this->group, $ttl); + } + + /** + * @inheritDoc + */ + public function add(string $key, $value, int $ttl): bool + { + if ($this->get($key) !== null) { + return false; + } + + return $this->put($key, $value, $ttl); + } + + /** + * @inheritDoc + */ + public function forever(string $key, $value): bool + { + return $this->put($key, $value, 0); + } + + /** + * @inheritDoc + */ + public function forget(string $key): bool + { + return wp_cache_delete($key, $this->group); + } + + /** + * @inheritDoc + */ + public function flush(): bool + { + return wp_cache_flush(); + } + + /** + * @inheritDoc + */ + public function increment(string $key, int $by = 1) + { + return wp_cache_incr($key, $by, $this->group); + } + + /** + * @inheritDoc + */ + public function decrement(string $key, int $by = 1) + { + return wp_cache_decr($key, $by, $this->group); + } +} diff --git a/tests/Cache/StoresTest.php b/tests/Cache/StoresTest.php new file mode 100644 index 0000000..f955f69 --- /dev/null +++ b/tests/Cache/StoresTest.php @@ -0,0 +1,217 @@ +fileStoreDirectory !== null && is_dir($this->fileStoreDirectory)) { + $this->removeDirectory($this->fileStoreDirectory); + } + + parent::tearDown(); + } + + public function testTransientStorePutGetRoundTrip(): void + { + $store = new TransientStore(); + + $this->assertTrue($store->put('k', 'v', 60)); + $this->assertSame('v', $store->get('k')); + $this->assertNull($store->get('missing')); + } + + public function testTransientStoreExpiresWhenCurrentTimeAdvancesPastTtl(): void + { + \WpKitTestState::$currentTime = '2024-01-01 00:00:00'; + $store = new TransientStore(); + + $store->put('k', 'v', 60); + $this->assertSame('v', $store->get('k')); + + \WpKitTestState::$currentTime = '2024-01-01 00:01:01'; + + $this->assertNull($store->get('k')); + } + + public function testTransientStoreForget(): void + { + $store = new TransientStore(); + $store->put('k', 'v', 60); + + $this->assertTrue($store->forget('k')); + $this->assertNull($store->get('k')); + } + + public function testTransientStoreFlushIsDocumentedNoOp(): void + { + $store = new TransientStore(); + $store->put('k', 'v', 60); + + $this->assertFalse($store->flush()); + $this->assertSame('v', $store->get('k'), 'flush() must not silently clear transients it cannot enumerate'); + } + + public function testTransientStoreIncrement(): void + { + $store = new TransientStore(); + $store->put('n', 1, 60); + + $this->assertSame(3, $store->increment('n', 2)); + $this->assertSame(1, $store->decrement('n', 2)); + } + + public function testTransientStorePrefixIsolatesKeys(): void + { + $a = new TransientStore('a_'); + $b = new TransientStore('b_'); + + $a->put('k', 'from-a', 60); + $b->put('k', 'from-b', 60); + + $this->assertSame('from-a', $a->get('k')); + $this->assertSame('from-b', $b->get('k')); + } + + public function testObjectCacheStorePutGetRoundTrip(): void + { + $store = new WpObjectCacheStore('test-group'); + + $this->assertTrue($store->put('k', 'v', 60)); + $this->assertSame('v', $store->get('k')); + $this->assertNull($store->get('missing')); + } + + public function testObjectCacheStoreForget(): void + { + $store = new WpObjectCacheStore('test-group'); + $store->put('k', 'v', 60); + + $this->assertTrue($store->forget('k')); + $this->assertNull($store->get('k')); + } + + public function testObjectCacheStoreFlush(): void + { + $store = new WpObjectCacheStore('test-group'); + $store->put('k', 'v', 60); + + $this->assertTrue($store->flush()); + $this->assertNull($store->get('k')); + } + + public function testObjectCacheStoreIncrement(): void + { + $store = new WpObjectCacheStore('test-group'); + $store->put('n', 1, 60); + + $this->assertSame(3, $store->increment('n', 2)); + $this->assertSame(1, $store->decrement('n', 2)); + } + + public function testObjectCacheStoreGroupIsolatesKeys(): void + { + $a = new WpObjectCacheStore('group-a'); + $b = new WpObjectCacheStore('group-b'); + + $a->put('k', 'from-a', 60); + $b->put('k', 'from-b', 60); + + $this->assertSame('from-a', $a->get('k')); + $this->assertSame('from-b', $b->get('k')); + } + + public function testFileStorePutGetRoundTrip(): void + { + $store = new FileStore($this->makeFileStoreDirectory()); + + $this->assertTrue($store->put('k', 'v', 60)); + $this->assertSame('v', $store->get('k')); + $this->assertNull($store->get('missing')); + } + + public function testFileStoreExpiresWhenClockAdvancesPastTtl(): void + { + $now = 1000; + $clock = function () use (&$now) { + return $now; + }; + $store = new FileStore($this->makeFileStoreDirectory(), '', $clock); + + $store->put('k', 'v', 60); + $this->assertSame('v', $store->get('k')); + + $now += 61; + + $this->assertNull($store->get('k')); + } + + public function testFileStoreForget(): void + { + $store = new FileStore($this->makeFileStoreDirectory()); + $store->put('k', 'v', 60); + + $this->assertTrue($store->forget('k')); + $this->assertNull($store->get('k')); + } + + public function testFileStoreFlushClearsDirectory(): void + { + $store = new FileStore($this->makeFileStoreDirectory()); + $store->put('a', '1', 60); + $store->put('b', '2', 60); + + $this->assertTrue($store->flush()); + $this->assertNull($store->get('a')); + $this->assertNull($store->get('b')); + } + + public function testFileStoreIncrement(): void + { + $store = new FileStore($this->makeFileStoreDirectory()); + $store->put('n', 1, 60); + + $this->assertSame(3, $store->increment('n', 2)); + $this->assertSame(1, $store->decrement('n', 2)); + } + + public function testFileStoreCreatesMissingDirectory(): void + { + $directory = $this->makeFileStoreDirectory() . '/nested'; + $store = new FileStore($directory); + + $this->assertTrue(is_dir($directory)); + $this->assertTrue($store->put('k', 'v', 60)); + $this->assertSame('v', $store->get('k')); + } + + private function makeFileStoreDirectory(): string + { + $this->fileStoreDirectory = sys_get_temp_dir() . '/wpkit-filestore-test-' . uniqid('', true); + + return $this->fileStoreDirectory; + } + + /** + * Recursively deletes a directory tree; used to clean up FileStore fixtures, including nested dirs. + */ + private function removeDirectory(string $directory): void + { + foreach (glob($directory . '/*') ?: [] as $path) { + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + + rmdir($directory); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0e9117d..bc4f96a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -56,6 +56,10 @@ final class WpKitTestState public static $wpVersion = '6.6'; public static $isAdmin = false; + + public static $transients = []; + + public static $objectCache = []; } class WP_Error @@ -218,6 +222,8 @@ function resetWpKitTestState() WpKitTestState::$multisite = false; WpKitTestState::$wpVersion = '6.6'; WpKitTestState::$isAdmin = false; + WpKitTestState::$transients = []; + WpKitTestState::$objectCache = []; $_GET = []; $_POST = []; @@ -507,6 +513,90 @@ function current_time($type) return WpKitTestState::$currentTime; } +function get_transient($key) +{ + $entry = WpKitTestState::$transients[$key] ?? null; + + if ($entry === null) { + return false; + } + + if ($entry['expires'] !== null && strtotime(WpKitTestState::$currentTime) >= $entry['expires']) { + unset(WpKitTestState::$transients[$key]); + + return false; + } + + return $entry['value']; +} + +function set_transient($key, $value, $ttl = 0) +{ + WpKitTestState::$transients[$key] = [ + 'value' => $value, + 'expires' => $ttl > 0 ? strtotime(WpKitTestState::$currentTime) + $ttl : null, + ]; + + return true; +} + +function delete_transient($key) +{ + $existed = array_key_exists($key, WpKitTestState::$transients); + unset(WpKitTestState::$transients[$key]); + + return $existed; +} + +function wp_cache_get($key, $group = '', $force = false, &$found = null) +{ + $found = array_key_exists($group, WpKitTestState::$objectCache) + && array_key_exists($key, WpKitTestState::$objectCache[$group]); + + return $found ? WpKitTestState::$objectCache[$group][$key] : false; +} + +function wp_cache_set($key, $value, $group = '', $ttl = 0) +{ + WpKitTestState::$objectCache[$group][$key] = $value; + + return true; +} + +function wp_cache_delete($key, $group = '') +{ + if (!isset(WpKitTestState::$objectCache[$group][$key])) { + return false; + } + + unset(WpKitTestState::$objectCache[$group][$key]); + + return true; +} + +function wp_cache_flush() +{ + WpKitTestState::$objectCache = []; + + return true; +} + +function wp_cache_incr($key, $offset = 1, $group = '') +{ + if (!isset(WpKitTestState::$objectCache[$group][$key])) { + return false; + } + + WpKitTestState::$objectCache[$group][$key] = (int) WpKitTestState::$objectCache[$group][$key] + $offset; + + return WpKitTestState::$objectCache[$group][$key]; +} + +function wp_cache_decr($key, $offset = 1, $group = '') +{ + return wp_cache_incr($key, -$offset, $group); +} + function wp_timezone_string() { return WpKitTestState::$options['timezone_string']; From d06234f67cd59133a8b796b147af979e6c416cfc Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 12:29:19 +0600 Subject: [PATCH 35/38] fix(cache): prefix-scope FileStore flush + review batch Scopes each FileStore prefix to its own sha1($prefix) subdirectory so flush() clears only this store's entries, letting sibling FileStores share a base directory without wiping each other. Also cleans up an orphaned .tmp file when rename() fails, documents wp_cache_flush()'s whole-cache blast radius and the store constructors, and adds add()/forever() coverage plus a FileStore cross-prefix flush-isolation test for all three stores. Assisted-By: AI --- src/Cache/Stores/FileStore.php | 20 +++++-- src/Cache/Stores/TransientStore.php | 3 + src/Cache/Stores/WpObjectCacheStore.php | 6 ++ tests/Cache/StoresTest.php | 77 +++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index 21ce832..4a07a73 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -11,8 +11,6 @@ final class FileStore implements Store { private string $directory; - private string $prefix; - /** * @var callable */ @@ -24,8 +22,9 @@ final class FileStore implements Store */ public function __construct(string $directory, string $prefix = '', ?callable $clock = null) { - $this->directory = rtrim($directory, '/\\'); - $this->prefix = $prefix; + // Scope each prefix to its own subdirectory so flush() only clears THIS store's entries, + // letting sibling FileStores share a base directory without wiping each other. + $this->directory = rtrim($directory, '/\\') . '/' . sha1($prefix); $this->clock = $clock ?? 'time'; if (!is_dir($this->directory)) { @@ -121,9 +120,12 @@ public function decrement(string $key, int $by = 1) return $this->increment($key, -$by); } + /** + * Maps a cache key to its on-disk file path within this store's prefix-scoped subdirectory. + */ private function path(string $key): string { - return $this->directory . '/' . sha1($this->prefix . $key); + return $this->directory . '/' . sha1($key); } /** @@ -168,6 +170,12 @@ private function write(string $key, $value, ?int $expiresAt): bool return false; } - return rename($tmp, $path); + if (!rename($tmp, $path)) { + unlink($tmp); + + return false; + } + + return true; } } diff --git a/src/Cache/Stores/TransientStore.php b/src/Cache/Stores/TransientStore.php index fb43b95..f2218cc 100644 --- a/src/Cache/Stores/TransientStore.php +++ b/src/Cache/Stores/TransientStore.php @@ -15,6 +15,9 @@ final class TransientStore implements Store { private string $prefix; + /** + * @param string $prefix prepended to every transient name to namespace this store's keys + */ public function __construct(string $prefix = '') { $this->prefix = $prefix; diff --git a/src/Cache/Stores/WpObjectCacheStore.php b/src/Cache/Stores/WpObjectCacheStore.php index ab2c13e..a278e81 100644 --- a/src/Cache/Stores/WpObjectCacheStore.php +++ b/src/Cache/Stores/WpObjectCacheStore.php @@ -15,6 +15,9 @@ final class WpObjectCacheStore implements Store { private string $group; + /** + * @param string $group WordPress object-cache group all of this store's keys are scoped to + */ public function __construct(string $group = 'default') { $this->group = $group; @@ -69,6 +72,9 @@ public function forget(string $key): bool /** * @inheritDoc + * + * Blast radius: wp_cache_flush() clears the ENTIRE object cache (every group and plugin), + * not just this store's group — WordPress exposes no group-scoped flush. */ public function flush(): bool { diff --git a/tests/Cache/StoresTest.php b/tests/Cache/StoresTest.php index f955f69..f421f13 100644 --- a/tests/Cache/StoresTest.php +++ b/tests/Cache/StoresTest.php @@ -84,6 +84,27 @@ public function testTransientStorePrefixIsolatesKeys(): void $this->assertSame('from-b', $b->get('k')); } + public function testTransientStoreAddRespectsExistingKey(): void + { + $store = new TransientStore(); + $store->put('k', 'original', 60); + + $this->assertFalse($store->add('k', 'replacement', 60)); + $this->assertSame('original', $store->get('k')); + } + + public function testTransientStoreForeverRoundTrip(): void + { + \WpKitTestState::$currentTime = '2024-01-01 00:00:00'; + $store = new TransientStore(); + + $this->assertTrue($store->forever('k', 'v')); + + \WpKitTestState::$currentTime = '2030-01-01 00:00:00'; + + $this->assertSame('v', $store->get('k')); + } + public function testObjectCacheStorePutGetRoundTrip(): void { $store = new WpObjectCacheStore('test-group'); @@ -132,6 +153,23 @@ public function testObjectCacheStoreGroupIsolatesKeys(): void $this->assertSame('from-b', $b->get('k')); } + public function testObjectCacheStoreAddRespectsExistingKey(): void + { + $store = new WpObjectCacheStore('test-group'); + $store->put('k', 'original', 60); + + $this->assertFalse($store->add('k', 'replacement', 60)); + $this->assertSame('original', $store->get('k')); + } + + public function testObjectCacheStoreForeverRoundTrip(): void + { + $store = new WpObjectCacheStore('test-group'); + + $this->assertTrue($store->forever('k', 'v')); + $this->assertSame('v', $store->get('k')); + } + public function testFileStorePutGetRoundTrip(): void { $store = new FileStore($this->makeFileStoreDirectory()); @@ -196,6 +234,45 @@ public function testFileStoreCreatesMissingDirectory(): void $this->assertSame('v', $store->get('k')); } + public function testFileStoreFlushIsPrefixScoped(): void + { + $baseDir = $this->makeFileStoreDirectory(); + $a = new FileStore($baseDir, 'a_'); + $b = new FileStore($baseDir, 'b_'); + + $a->put('k', 'from-a', 60); + $b->put('k', 'from-b', 60); + + $this->assertTrue($a->flush()); + + $this->assertNull($a->get('k'), 'flushed store must be cleared'); + $this->assertSame('from-b', $b->get('k'), 'sibling prefix on the same directory must survive'); + } + + public function testFileStoreAddRespectsExistingKey(): void + { + $store = new FileStore($this->makeFileStoreDirectory()); + $store->put('k', 'original', 60); + + $this->assertFalse($store->add('k', 'replacement', 60)); + $this->assertSame('original', $store->get('k')); + } + + public function testFileStoreForeverNeverExpires(): void + { + $now = 1000; + $clock = function () use (&$now) { + return $now; + }; + $store = new FileStore($this->makeFileStoreDirectory(), '', $clock); + + $this->assertTrue($store->forever('k', 'v')); + + $now += 315360000; + + $this->assertSame('v', $store->get('k')); + } + private function makeFileStoreDirectory(): string { $this->fileStoreDirectory = sys_get_temp_dir() . '/wpkit-filestore-test-' . uniqid('', true); From da63d99df6e818a930e9294085ea22e9f8c2e7a1 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 12:34:33 +0600 Subject: [PATCH 36/38] feat(cache): manager + facade Wires the Cache stores/Repository (Tasks 6-7) behind a CacheManager that resolves array|transient|object|file stores from a single config array and memoises Repository instances per name, plus a Cache static facade mirroring the Hooks forwarder style. Assisted-By: AI --- src/Cache/Cache.php | 64 +++++++++++++++++++ src/Cache/CacheManager.php | 98 +++++++++++++++++++++++++++++ tests/Cache/CacheManagerTest.php | 103 +++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/Cache/Cache.php create mode 100644 src/Cache/CacheManager.php create mode 100644 tests/Cache/CacheManagerTest.php diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php new file mode 100644 index 0000000..0ee515e --- /dev/null +++ b/src/Cache/Cache.php @@ -0,0 +1,64 @@ +store()->{$method}(...$parameters); + } + + /** + * Set the CacheManager the facade forwards calls to; required before any other facade call. + */ + public static function setManager(CacheManager $manager): void + { + self::$_manager = $manager; + } + + /** + * Resolve the named (or configured default) Repository from the current manager. + */ + public static function store(?string $name = null): Repository + { + return self::getManager()->store($name); + } + + /** + * Clears the configured manager; intended for test isolation between cases. + */ + public static function reset(): void + { + self::$_manager = null; + } + + private static function getManager(): CacheManager + { + if (self::$_manager === null) { + throw new RuntimeException('Cache facade used before Cache::setManager() was called.'); + } + + return self::$_manager; + } +} diff --git a/src/Cache/CacheManager.php b/src/Cache/CacheManager.php new file mode 100644 index 0000000..29b0281 --- /dev/null +++ b/src/Cache/CacheManager.php @@ -0,0 +1,98 @@ + + */ + private array $repositories = []; + + /** + * @param array{default?:string,prefix?:string,stores?:array>} $config + */ + public function __construct(private array $config = []) + { + } + + /** + * Resolve (and memoise) the Repository for a named store, or the configured default when null. + */ + public function store(?string $name = null): Repository + { + $name = $name ?? $this->defaultStoreName(); + + if (!isset($this->repositories[$name])) { + $this->repositories[$name] = new Repository($this->makeStore($name)); + } + + return $this->repositories[$name]; + } + + /** + * Builds the raw Store backend for a store name, namespaced with the manager's prefix. + */ + private function makeStore(string $name): Store + { + switch ($name) { + case 'array': + return new ArrayStore(); + + case 'transient': + return new TransientStore($this->prefix()); + + case 'object': + return new WpObjectCacheStore($this->objectCacheGroup()); + + case 'file': + return new FileStore($this->fileStorePath(), $this->prefix()); + + default: + throw new InvalidArgumentException("Unknown cache store [{$name}]."); + } + } + + private function defaultStoreName(): string + { + return $this->config['default'] ?? 'transient'; + } + + private function prefix(): string + { + return $this->config['prefix'] ?? ''; + } + + private function objectCacheGroup(): string + { + $group = $this->config['stores']['object']['group'] ?? $this->prefix(); + + return $group !== '' ? $group : 'default'; + } + + /** + * Resolves the configured `stores.file.path`, throwing when the `file` store is requested + * without one — FileStore has no directory to fall back to. + */ + private function fileStorePath(): string + { + $path = $this->config['stores']['file']['path'] ?? null; + + if (!\is_string($path) || $path === '') { + throw new InvalidArgumentException('Cache store [file] requires a configured stores.file.path.'); + } + + return $path; + } +} diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php new file mode 100644 index 0000000..a436ccf --- /dev/null +++ b/tests/Cache/CacheManagerTest.php @@ -0,0 +1,103 @@ + 'array', 'prefix' => 'bit_smtp_']); + + $repository = $manager->store(); + + $this->assertInstanceOf(Repository::class, $repository); + $this->assertSame($repository, $manager->store('array'), 'null must resolve to the configured default store name'); + } + + public function testNamedArrayStoreRoundTripsAValue(): void + { + $manager = new CacheManager(['default' => 'transient']); + $repository = $manager->store('array'); + + $this->assertTrue($repository->put('k', 'v', 60)); + $this->assertSame('v', $repository->get('k')); + } + + public function testRepeatedStoreCallsReturnTheSameRepositoryInstance(): void + { + $manager = new CacheManager(['default' => 'array']); + + $this->assertSame($manager->store('array'), $manager->store('array')); + } + + public function testFileStoreWithoutConfiguredPathThrows(): void + { + $manager = new CacheManager(['default' => 'array']); + + $this->expectException(InvalidArgumentException::class); + + $manager->store('file'); + } + + public function testFileStoreWithConfiguredPathRoundTripsAValue(): void + { + $directory = sys_get_temp_dir() . '/wpkit-cachemanager-test-' . uniqid('', true); + $manager = new CacheManager([ + 'default' => 'array', + 'prefix' => 'bit_smtp_', + 'stores' => ['file' => ['path' => $directory]], + ]); + + $repository = $manager->store('file'); + + $this->assertTrue($repository->put('k', 'v', 60)); + $this->assertSame('v', $repository->get('k')); + + $this->removeDirectory($directory); + } + + public function testFacadeForwardsRememberToTheDefaultStoreAfterSetManager(): void + { + Cache::setManager(new CacheManager(['default' => 'array'])); + + $value = Cache::remember('k', 60, function () { + return 'v'; + }); + + $this->assertSame('v', $value); + $this->assertInstanceOf(Repository::class, Cache::store('array')); + } + + public function testFacadeUsedBeforeSetManagerThrows(): void + { + $this->expectException(RuntimeException::class); + + Cache::store(); + } + + /** + * Recursively deletes a directory tree; used to clean up FileStore fixtures. + */ + private function removeDirectory(string $directory): void + { + foreach (glob($directory . '/*') ?: [] as $path) { + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + + rmdir($directory); + } +} From c8c94fdc0de285881a13e06a0f24c5508c2852b4 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 23 Aug 2026 12:38:58 +0600 Subject: [PATCH 37/38] docs(wp-kit): document new subsystems Append Container, Settings, Cron, and Cache subsystem sections to README with usage examples. Assisted-By: AI --- README.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/README.md b/README.md index 6061a22..2ec66ff 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,166 @@ declared HTTP methods. Their actions must return string-compatible page content; are unchanged. Modern `Edg/` user agents are recognized as Edge, and OS matching no longer suppresses malformed regular-expression warnings. +## Container + +Lightweight IoC container for managing service bindings and providers. Supports +constructor autowiring: when resolving a type, dependencies are automatically +injected if their classes are type-hinted in the constructor. + +```php +use BitApps\WPKit\Container\Application; +use BitApps\WPKit\Container\ServiceProvider; + +// Build a container with service providers. +$app = new Application(); + +// Register a simple binding. +$app->bind(Logger::class, FileLogger::class); + +// Register a shared singleton instance (same instance every time). +$app->singleton(Database::class, function ($container) { + return new Database($container->make(Connection::class)); +}); + +// Resolve and autowire dependencies. +$logger = $app->make(Logger::class); // resolves as FileLogger +$db = $app->make(Database::class); // autowires Connection + +// Register a service provider. +class MailProvider extends ServiceProvider { + public function register(): void { + $this->app->singleton(Mailer::class); + } + + public function boot(): void { + // Bootstrap logic after all providers are registered. + } +} + +$app->register(MailProvider::class); + +// Boot all registered providers. +$app->boot(); +``` + +## Settings + +Typed get/set/save access to wp_options rows. Define a schema of typed fields, +then use a `SettingsRepository` to load, modify, and persist them with automatic +casting and sanitization. + +```php +use BitApps\WPKit\Settings\SettingField; +use BitApps\WPKit\Settings\SettingsSchema; +use BitApps\WPKit\Settings\SettingsRepository; + +// Define a schema with typed fields. +$schema = (new SettingsSchema()) + ->add( + SettingField::bool('enabled', false, 'general'), + SettingField::int('max_retries', 3, 'general'), + SettingField::enum('log_level', ['debug', 'info', 'error'], 'info', 'logging'), + SettingField::string('api_key', '', 'api') + ); + +// Create a repository for the wp_options row. +$repo = new SettingsRepository('myplugin_settings', $schema); + +// Get typed values (with automatic casting from stored values). +$enabled = $repo->get('enabled'); // bool +$level = $repo->get('log_level'); // string (validated against choices) + +// Set values (cast to field type). +$repo->set('enabled', '1')->set('max_retries', '5'); + +// Bulk update and save. +$repo->fill(['enabled' => true, 'api_key' => 'secret']); +$repo->save(); +``` + +## Cron + +Registers custom cron schedules and recurring/one-off jobs. Wire callbacks onto +WordPress cron hooks with fluent scheduling. + +```php +use BitApps\WPKit\Cron\Scheduler; + +$scheduler = new Scheduler(); + +// Define a custom cron interval. +$scheduler->addSchedule('every_minute', 60, 'Every Minute'); + +// Register a recurring job. +$scheduler + ->job('myplugin_hourly_sync', 'hourly', function () { + // Sync data every hour. + }) + ->job('myplugin_sync', 'every_minute', function () { + // Custom schedule set via addSchedule. + }); + +// Register a one-off job. +$scheduler->once('myplugin_one_time', time() + 3600, function () { + // Fire once, in 1 hour. +}); + +// Wire hooks and schedule pending events. +$scheduler->boot(); + +// On plugin deactivation, clear all scheduled events. +$scheduler->clearAll(); +``` + +## Cache + +Manages named cache stores (array, transient, WP object cache, or file). Access +stores via the manager, or use the static `Cache` facade. + +```php +use BitApps\WPKit\Cache\CacheManager; +use BitApps\WPKit\Cache\Cache; + +// Configure a manager with multiple stores. +$manager = new CacheManager([ + 'default' => 'transient', + 'prefix' => 'myplugin_', + 'stores' => [ + 'file' => ['path' => '/var/cache/myplugin'], + 'object' => ['group' => 'myplugin_group'], + ], +]); + +// Access a named store (defaults to 'transient'). +$cache = $manager->store('file'); + +// Cache operations. +$cache->put('user_123', $userData, 3600); +$user = $cache->get('user_123'); + +// Callback-based caching (compute and store on miss). +$data = $cache->remember('expensive_key', 7200, function () { + return compute_expensive_data(); +}); + +$cache->forget('user_123'); +$cache->flush(); + +// Use the static facade (requires setManager first). +Cache::setManager($manager); +$data = Cache::remember('cached_posts', 3600, function () { + return get_posts(['numberposts' => 10]); +}); + +// Available stores: +// - 'array': in-memory only, lost on shutdown. +// - 'transient': WordPress transients (data persists across requests). +// - 'object': WordPress object cache (non-persistent unless a drop-in is installed). +// - 'file': filesystem, requires configured path. + +// Note: TransientStore::flush() is a documented no-op (WordPress limitation). +``` + ## Tests ```bash From 586152324d8e8fd27e75170d23be7e439cbfc1f8 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 25 Aug 2026 21:54:47 +0600 Subject: [PATCH 38/38] feat(installer): provision late-created multisite subsites Register a wp_initialize_site handler (priority 20, after core creates the blog's own tables) when the multisite requirement is set, so a subsite created after network activation gets the plugin schema the activation-time loop never covered. provisionNewSite runs the idempotent migration list under switch_to_blog/restore (restore in a finally), gated by a new isNetworkActive() so tables are never created on a site not running the plugin network-wide. Assisted-By: AI --- src/Installer.php | 48 +++++++++ tests/Lifecycle/MigrationAndInstallerTest.php | 102 ++++++++++++++++++ tests/bootstrap.php | 8 ++ 3 files changed, 158 insertions(+) diff --git a/src/Installer.php b/src/Installer.php index aa75a74..e3ab7e9 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -43,6 +43,13 @@ public function register(): void // Only a static class method or function can be used in an uninstall hook. Hooks::addAction($this->_hooks['uninstall'], [self::class, 'uninstall']); } + + // On multisite, a subsite created after activation never runs the activation-time + // provisioning loop; provision its schema when WordPress initialises the new site. Priority + // 20 runs after core's own priority-10 handler that creates the blog's options/core tables. + if (!empty($this->_requirements['multisite'])) { + add_action('wp_initialize_site', [$this, 'provisionNewSite'], 20); + } } public function activate($isNetworkActivation): void @@ -75,6 +82,47 @@ public function activateOnMultiSite(): void } } + /** + * Provision the plugin schema on a subsite created after network activation, which the + * activation-time loop never covers. Idempotent (migrations are CREATE TABLE IF NOT EXISTS); + * gated to network-active multisite installs so tables are never created on a site not running + * the plugin. + * + * @param object $newSite the WP_Site for the just-created blog + */ + public function provisionNewSite($newSite): void + { + if (!is_multisite() || !$this->isNetworkActive() || !isset($newSite->blog_id)) { + return; + } + + switch_to_blog((int) $newSite->blog_id); + + try { + MigrationHelper::migrate($this->_migration); + } finally { + // Restore in a finally so a migration throw can't leave the wrong blog switched. + restore_current_blog(); + } + } + + /** + * Whether the plugin is active network-wide; gates subsite provisioning to network activations so + * a new subsite never gets tables for a plugin that is not actually running network-wide. + */ + public function isNetworkActive(): bool + { + if (empty($this->_requirements['basename'])) { + return false; + } + + if (!\function_exists('is_plugin_active_for_network')) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return is_plugin_active_for_network($this->_requirements['basename']); + } + public static function uninstall(): void { if (is_multisite()) { diff --git a/tests/Lifecycle/MigrationAndInstallerTest.php b/tests/Lifecycle/MigrationAndInstallerTest.php index d884f83..c54a108 100644 --- a/tests/Lifecycle/MigrationAndInstallerTest.php +++ b/tests/Lifecycle/MigrationAndInstallerTest.php @@ -25,6 +25,7 @@ function contractInstallerRequirements() 'php' => '8.0', 'wp' => '6.0', 'multisite' => true, + 'basename' => 'plugin/plugin.php', ]; } @@ -118,6 +119,107 @@ public function testInstallerNetworkActivationVisitsAndRestoresEverySite(): void assertSameValue(2, ContractMigration::$upCalls, 'network migration count changed'); } + public function testInstallerRegisterWiresNewSiteProvisioningOnMultisite(): void + { + $installer = new Installer( + contractInstallerRequirements(), + ['activate' => 'plugin_activate'], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->register(); + + assertTest( + isset(WpKitTestState::$actions['wp_initialize_site']), + 'new-site provisioning hook was not wired on multisite', + ); + } + + public function testInstallerRegisterSkipsNewSiteProvisioningWhenNotMultisite(): void + { + $requirements = contractInstallerRequirements(); + unset($requirements['multisite']); + $installer = new Installer( + $requirements, + ['activate' => 'plugin_activate'], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->register(); + + assertTest( + !isset(WpKitTestState::$actions['wp_initialize_site']), + 'new-site provisioning hook was wired without a multisite requirement', + ); + } + + public function testInstallerProvisionsNewSubsiteOnNetworkActiveMultisite(): void + { + resetContractMigrationCalls(); + WpKitTestState::$multisite = true; + WpKitTestState::$networkActive = true; + $installer = new Installer( + contractInstallerRequirements(), + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->provisionNewSite((object) ['blog_id' => 7]); + + assertSameValue([7], WpKitTestState::$switchedBlogs, 'new subsite was not switched to'); + assertSameValue(1, WpKitTestState::$restoredBlogs, 'new subsite blog was not restored'); + assertSameValue(1, ContractMigration::$upCalls, 'new subsite migration was not run once'); + } + + public function testInstallerSkipsProvisioningWhenNotMultisite(): void + { + resetContractMigrationCalls(); + WpKitTestState::$multisite = false; + WpKitTestState::$networkActive = true; + $installer = new Installer( + contractInstallerRequirements(), + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->provisionNewSite((object) ['blog_id' => 7]); + + assertSameValue([], WpKitTestState::$switchedBlogs, 'non-multisite install provisioned a subsite'); + assertSameValue(0, ContractMigration::$upCalls, 'non-multisite install ran a migration'); + } + + public function testInstallerSkipsProvisioningWhenNotNetworkActive(): void + { + resetContractMigrationCalls(); + WpKitTestState::$multisite = true; + WpKitTestState::$networkActive = false; + $installer = new Installer( + contractInstallerRequirements(), + [], + [ + 'migration' => contractMigrationConfiguration(), + 'drop' => contractMigrationConfiguration(), + ], + ); + + $installer->provisionNewSite((object) ['blog_id' => 7]); + + assertSameValue([], WpKitTestState::$switchedBlogs, 'non-network-active install provisioned a subsite'); + assertSameValue(0, ContractMigration::$upCalls, 'non-network-active install ran a migration'); + } + public function testInstallerUninstallDropsConfiguredMigrations(): void { resetContractMigrationCalls(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bc4f96a..0322ad7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -53,6 +53,8 @@ final class WpKitTestState public static $multisite = false; + public static $networkActive = false; + public static $wpVersion = '6.6'; public static $isAdmin = false; @@ -220,6 +222,7 @@ function resetWpKitTestState() WpKitTestState::$switchedBlogs = []; WpKitTestState::$restoredBlogs = 0; WpKitTestState::$multisite = false; + WpKitTestState::$networkActive = false; WpKitTestState::$wpVersion = '6.6'; WpKitTestState::$isAdmin = false; WpKitTestState::$transients = []; @@ -698,3 +701,8 @@ function is_multisite() { return WpKitTestState::$multisite; } + +function is_plugin_active_for_network($basename) +{ + return WpKitTestState::$networkActive; +}