diff --git a/src/Http/Request/InteractsWithHeaders.php b/src/Http/Request/InteractsWithHeaders.php new file mode 100644 index 0000000..f0f43e7 --- /dev/null +++ b/src/Http/Request/InteractsWithHeaders.php @@ -0,0 +1,104 @@ + + */ + public function headers() + { + return array_map('sanitize_text_field', $this->rawHeaders()); + } + + /** + * Returns one sanitized header by any name style ("X-Token" or "x_token"), or the default when absent. + * + * @return null|string + */ + public function header(string $name, ?string $default = null) + { + $value = $this->rawHeader($name); + + return $value === null ? $default : sanitize_text_field($value); + } + + /** + * Returns one header exactly as sent, for signature or token checks only; never output or store it unsanitized. + * + * @return null|string + */ + public function rawHeader(string $name, ?string $default = null) + { + return $this->rawHeaders()[self::canonicalHeaderName($name)] ?? $default; + } + + /** + * Reports whether the request carries the header. + * + * @return bool + */ + public function hasHeader(string $name) + { + return \array_key_exists(self::canonicalHeaderName($name), $this->rawHeaders()); + } + + /** + * Collects unsanitized headers from the REST request when hydrated, else from the server globals. + * + * @return array + */ + private function rawHeaders(): array + { + if (isset($this->rest) && $this->rest instanceof WP_REST_Request) { + return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers()); + } + + return $this->serverHeaders(); + } + + /** + * Collects headers from $_SERVER, which WordPress slashes. + * + * @return array + */ + private function serverHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (!\is_string($value)) { + continue; + } + + if (str_starts_with($key, 'HTTP_')) { + $headers[self::canonicalHeaderName(substr($key, 5))] = wp_unslash($value); + } elseif ($value !== '' && \in_array($key, self::$_unprefixedHeaders, true)) { + // FastCGI passes CONTENT_* as '' when the request has no body. + $headers[self::canonicalHeaderName($key)] = wp_unslash($value); + } + } + + return $headers; + } + + /** + * Normalizes a header name the way WP_REST_Request does. + */ + private static function canonicalHeaderName(string $name): string + { + return str_replace('-', '_', strtolower($name)); + } +} diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php index 7fb16bc..60fa9db 100644 --- a/src/Http/Request/Request.php +++ b/src/Http/Request/Request.php @@ -16,8 +16,11 @@ class Request extends Validator implements ArrayAccess, JsonSerializable { + use InteractsWithHeaders; use IpTool; + private const APPLICATION_MEDIA_PREFIX = 'application/'; + protected $rest; protected array $attributes = []; @@ -28,6 +31,11 @@ class Request extends Validator implements ArrayAccess, JsonSerializable protected $body = []; + /** + * Raw php://input, read at most once. + */ + private ?string $_content = null; + /** * Undocumented function. */ @@ -98,14 +106,47 @@ public function body() return $this->body; } + /** + * Returns the raw request body exactly as sent, e.g. for signature checks. + * + * @return string + */ + public function content() + { + if (isset($this->rest)) { + return (string) $this->rest->get_body(); + } + + return $this->_content ??= (string) file_get_contents('php://input'); + } + + /** + * Returns the sanitized, upper-cased HTTP method, or '' when the server omits it. + * + * @return string + */ public function method() { - return sanitize_text_field($_SERVER['REQUEST_METHOD']); + if (!isset($_SERVER['REQUEST_METHOD'])) { + return ''; + } + + return strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD']))); } + /** + * Returns the sanitized Content-Type with a leading "application/" removed (e.g. "json"). + * + * @return string + */ public function contentType() { - return isset($_SERVER['CONTENT_TYPE']) ? ltrim($_SERVER['CONTENT_TYPE'], 'application/') : ''; + $type = (string) $this->header('Content-Type', ''); + + // Media types are case-insensitive (RFC 9110 ยง8.3.1). + return str_starts_with(strtolower($type), self::APPLICATION_MEDIA_PREFIX) + ? substr($type, \strlen(self::APPLICATION_MEDIA_PREFIX)) + : $type; } public function setApiRequest(WP_REST_Request $request) @@ -246,7 +287,7 @@ protected function setBody($body = []) !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()); + $this->body = JSON::maybeDecode($this->content(), JsonConfig::decodeAsArray()); } if (!empty($_POST)) { diff --git a/src/Http/Router/RewriteRuleSet.php b/src/Http/Router/RewriteRuleSet.php index ab8eff8..b8f8c05 100644 --- a/src/Http/Router/RewriteRuleSet.php +++ b/src/Http/Router/RewriteRuleSet.php @@ -18,14 +18,15 @@ public function __construct(string $pageName) $this->_pageName = trim($pageName, '/'); } + /** + * Adds the rule for one route path; only an empty path maps the page root itself. + */ public function addPath(string $path): void { - if (empty($this->_rules)) { - $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; - } - $path = trim($path, '/'); if ($path === '') { + $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; + return; } diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 8c938fe..3a3963e 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -4,6 +4,7 @@ use BitApps\WPKit\Http\RequestType; use BitApps\WPKit\Http\Response; +use WP; if (!\defined('ABSPATH')) { exit; @@ -11,6 +12,13 @@ class StaticRouter { + /** + * Seconds to wait before re-flushing rules that did not persist (e.g. filtered out by another plugin). + */ + private const FLUSH_RETRY_SECONDS = 300; + + private const FLUSH_RETRY_TRANSIENT_PREFIX = 'wpkit_static_flush_'; + private Router $router; private string $pageName; @@ -34,9 +42,13 @@ public function flushOnActivate(): void flush_rewrite_rules(); } + /** + * Empties the persisted rules so the next request rebuilds them without ours; a flush here would + * re-save them, as they are still registered on the deactivating request. + */ public function flushOnDeactivate(): void { - flush_rewrite_rules(); + update_option('rewrite_rules', ''); } public function registerRewriteRules(): void @@ -59,10 +71,27 @@ public function addQueryVars($vars): array return array_merge($vars, $this->queryVars); } - public function handleRequest(): void + /** + * Dispatches on parse_request instead of template_redirect, before the main query runs or a 404 is + * flagged; for routes that send their own response and exit (callbacks, webhooks), not page content. + */ + public function dispatchOnParseRequest(): self + { + remove_action('template_redirect', [$this, 'handleRequest']); + add_action('parse_request', [$this, 'handleRequest']); + + return $this; + } + + /** + * Runs the first route matching the request; parse_request passes the WP object, template_redirect nothing. + * + * @param mixed $wp + */ + public function handleRequest($wp = null): void { - $requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); - $method = strtoupper(sanitize_text_field($_SERVER['REQUEST_METHOD'] ?? 'GET')); + $requestPath = $this->requestPath($wp instanceof WP ? $wp : ($GLOBALS['wp'] ?? null)); + $method = strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET'))); foreach ($this->router->getRoutes() as $route) { if (!\in_array($method, $route->getMethods(), true)) { continue; @@ -122,12 +151,26 @@ public static function isRewriteExists(?string $path = '', ?array $rewriteRules return true; } + /** + * Flushes when our rules are missing from the persisted set, at most once per retry window, and never + * under plain permalinks where rewrite rules are unused. + */ public function maybeFlushRewriteRules(): void { - if (empty($this->rewriteRules) || self::isRewriteExists('', $this->rewriteRules)) { + if ( + empty($this->rewriteRules) + || !get_option('permalink_structure') + || self::isRewriteExists('', $this->rewriteRules) + ) { + return; + } + + $retryKey = self::FLUSH_RETRY_TRANSIENT_PREFIX . $this->pageName; + if (get_transient($retryKey) !== false) { return; } + set_transient($retryKey, 1, self::FLUSH_RETRY_SECONDS); flush_rewrite_rules(); } @@ -140,6 +183,22 @@ private function registerHooks(string $activationHook, string $deactivationHook) add_action('template_redirect', [$this, 'handleRequest']); } + /** + * Request path relative to the site root: WP's parsed path when set (strips home and index.php), + * else the request URI minus the home path (plain permalinks). + */ + private function requestPath(?WP $wp): string + { + if ($wp !== null && \is_string($wp->request) && $wp->request !== '') { + return sanitize_url('/' . trim($wp->request, '/')); + } + + $path = (string) wp_parse_url(sanitize_url(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH); + $homePath = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/'); + + return $homePath !== '' && str_starts_with($path, $homePath . '/') ? substr($path, \strlen($homePath)) : $path; + } + private function processRoutes(): void { $ruleSet = new RewriteRuleSet($this->pageName); diff --git a/tests/Fixtures/FakePhpInputStream.php b/tests/Fixtures/FakePhpInputStream.php new file mode 100644 index 0000000..9998b0c --- /dev/null +++ b/tests/Fixtures/FakePhpInputStream.php @@ -0,0 +1,88 @@ +_position = 0; + + return true; + } + + /** + * Reads the next chunk of the fake body. + */ + public function stream_read(int $count): string + { + $chunk = substr(self::$_content, $this->_position, $count); + $this->_position += \strlen($chunk); + + return $chunk; + } + + /** + * Reports whether the fake body is exhausted. + */ + public function stream_eof(): bool + { + return $this->_position >= \strlen(self::$_content); + } + + /** + * Returns empty stat data; file_get_contents() only needs the call to succeed. + */ + public function stream_stat(): array + { + return []; + } +} diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index ed48cd3..8e3afcc 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -3,6 +3,7 @@ namespace BitApps\WPKit\Tests\Http; use BitApps\WPKit\Http\Request\Request; +use BitApps\WPKit\Tests\Fixtures\FakePhpInputStream; use BitApps\WPKit\Tests\TestCase; use WP_REST_Request; use WpKitTestState; @@ -49,6 +50,14 @@ public function getRoute() */ final class RequestTest extends TestCase { + /** Puts the built-in php:// wrapper back after a test faked php://input. */ + protected function tearDown(): void + { + FakePhpInputStream::uninstall(); + + parent::tearDown(); + } + public function testConsumerSubclassMayOverrideAccessorsWithUntypedSignatures(): void { $request = new ContractOverridingRequest(); @@ -147,4 +156,164 @@ public function testRequestSuccessfulValidationReturnsValidatedValues(): void assertSameValue(['name' => 'Ada'], $validated, 'successful validation result changed'); assertSameValue(null, WpKitTestState::$sentJson, 'successful validation emitted an error response'); } + + public function testRequestHeaderIsReadFromServerByAnyNameStyle(): void + { + $_SERVER['HTTP_X_SETUP_TOKEN'] = 'abc'; + $request = new Request(); + + assertSameValue('abc', $request->header('X-Setup-Token'), 'dashed header name not resolved'); + assertSameValue('abc', $request->header('x_setup_token'), 'canonical header name not resolved'); + assertTest($request->hasHeader('x-setup-token'), 'hasHeader missed a present header'); + } + + public function testRequestContentHeadersAreExposedWithoutTheHttpPrefix(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['CONTENT_LENGTH'] = '42'; + $request = new Request(); + + assertSameValue('application/json', $request->header('Content-Type'), 'content type header missing'); + assertSameValue('42', $request->header('Content-Length'), 'content length header missing'); + } + + public function testRequestMissingHeaderFallsBackToTheDefault(): void + { + $request = new Request(); + + assertSameValue(null, $request->header('X-Missing'), 'missing header was not null'); + assertSameValue('fallback', $request->header('X-Missing', 'fallback'), 'missing header ignored the default'); + assertSameValue(false, $request->hasHeader('X-Missing'), 'hasHeader reported a missing header'); + } + + public function testRequestHeadersAreKeyedByCanonicalName(): void + { + $_SERVER['HTTP_ACCEPT'] = 'application/json'; + $_SERVER['HTTP_X_TRACE'] = 't-1'; + $_SERVER['HTTP_X_ARRAY'] = ['not', 'a', 'string']; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $request = new Request(); + + assertSameValue( + ['accept' => 'application/json', 'x_trace' => 't-1'], + $request->headers(), + 'header map changed', + ); + } + + public function testRequestHeaderValuesAreSanitizedByDefault(): void + { + $_SERVER['HTTP_X_NAME'] = " Ada\n\tLovelace %41 "; + $_SERVER['HTTP_X_TRACE'] = 't-1'; + $request = new Request(); + + assertSameValue('Ada Lovelace', $request->header('X-Name'), 'header() returned an unsanitized value'); + assertSameValue(['x_name' => 'Ada Lovelace', 'x_trace' => 't-1'], $request->headers(), 'headers() returned unsanitized values'); + } + + public function testRequestRawHeaderReturnsTheExactValueForVerification(): void + { + $_SERVER['HTTP_X_SIGNATURE'] = "t=1,v1=ab+/= %41"; + $request = new Request(); + + assertSameValue("t=1,v1=ab+/= %41", $request->rawHeader('X-Signature'), 'raw header value was altered'); + assertSameValue('fallback', $request->rawHeader('X-Missing', 'fallback'), 'raw header ignored the default'); + } + + public function testRequestRESTRequestHeadersTakePrecedenceOverServer(): void + { + $_SERVER['HTTP_X_SIGNATURE'] = 'from-server'; + $request = new Request(); + $request->setApiRequest(new WP_REST_Request([], [], [], [], ['x_signature' => ['from-rest'], 'accept' => ['a', 'b']])); + + assertSameValue('from-rest', $request->header('X-Signature'), 'REST header did not win'); + assertSameValue('from-rest', $request->rawHeader('X-Signature'), 'REST raw header did not win'); + assertSameValue('a, b', $request->header('Accept'), 'multi-value REST header not joined'); + } + + public function testRequestContentReturnsTheRawBodyByteForByte(): void + { + $payload = "{\"b\": 2, \"a\":\"\\u00e9\"}\n"; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + FakePhpInputStream::install($payload); + $request = new Request(); + + assertSameValue($payload, $request->content(), 'raw body was altered'); + assertSameValue(['b' => 2, 'a' => "\u{e9}"], $request->body(), 'decoded body changed'); + } + + public function testRequestContentReturnsNonJsonPayloadsVerbatim(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + FakePhpInputStream::install('a=1&b=%20+x'); + + assertSameValue('a=1&b=%20+x', (new Request())->content(), 'form payload was altered'); + } + + public function testRequestContentIsEmptyWithoutABody(): void + { + FakePhpInputStream::install(''); + + assertSameValue('', (new Request())->content(), 'empty body was not an empty string'); + } + + public function testRequestReadsTheInputStreamOnce(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + FakePhpInputStream::install('{"a":1}'); + $request = new Request(); + + $request->content(); + $request->content(); + + assertSameValue(1, FakePhpInputStream::$opens, 'php://input was read more than once'); + } + + public function testRequestContentPrefersTheHydratedRESTBody(): void + { + FakePhpInputStream::install('from-input'); + $request = new Request(); + $request->setApiRequest(new WP_REST_Request([], [], [], [], [], 'from-rest')); + + assertSameValue('from-rest', $request->content(), 'REST raw body did not win'); + } + + public function testRequestMethodIsSanitizedAndUpperCased(): void + { + $_SERVER['REQUEST_METHOD'] = ' patch '; + + assertSameValue('PATCH', (new Request())->method(), 'request method was not sanitized'); + } + + public function testRequestMethodIsEmptyWhenTheServerOmitsIt(): void + { + unset($_SERVER['REQUEST_METHOD']); + + assertSameValue('', (new Request())->method(), 'missing request method was not empty'); + } + + public function testRequestContentTypeStripsOnlyTheApplicationPrefix(): void + { + $cases = [ + 'application/json; charset=utf-8' => 'json; charset=utf-8', + 'application/pdf' => 'pdf', + 'Application/XML' => 'XML', + 'text/plain' => 'text/plain', + 'multipart/form-data; boundary=x' => 'multipart/form-data; boundary=x', + ' text/csv ' => 'text/csv', + ]; + + foreach ($cases as $header => $expected) { + $_SERVER['CONTENT_TYPE'] = $header; + + assertSameValue($expected, (new Request())->contentType(), "content type mangled: {$header}"); + } + } + + public function testRequestContentTypeIsEmptyWhenTheServerOmitsIt(): void + { + unset($_SERVER['CONTENT_TYPE']); + + assertSameValue('', (new Request())->contentType(), 'missing content type was not empty'); + } } diff --git a/tests/Router/RewriteRuleSetTest.php b/tests/Router/RewriteRuleSetTest.php index 911a10e..da78411 100644 --- a/tests/Router/RewriteRuleSetTest.php +++ b/tests/Router/RewriteRuleSetTest.php @@ -43,7 +43,6 @@ public function testAddPathBuildsOneCompleteRuleAndQueryVars(): void assertSameValue( [ - '^landing/?$' => 'index.php?pagename=landing', '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]', ], $set->rules(), @@ -63,6 +62,14 @@ public function testRulesAccumulateAcrossPaths(): void assertSameValue(['a', 'b'], $set->queryVars(), 'query vars did not accumulate'); } + public function testEmptyPathRegistersOnlyThePageRootRule(): void + { + $set = new RewriteRuleSet('landing'); + $set->addPath('/'); + + assertSameValue(['^landing/?$' => 'index.php?pagename=landing'], $set->rules(), 'empty path did not map to the page root'); + } + public function testEmptySetHasNoRulesOrQueryVars(): void { $set = new RewriteRuleSet('landing'); diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index e277081..fae7889 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -92,7 +92,7 @@ public function testRewriteRulesMapPageAndParameterSegmentsToQueryVars(): void do_action('init'); $rules = WpKitTestState::$rewriteRules; - assertSameValue('index.php?pagename=landing', $rules['^landing/?$']['query'] ?? null, 'page rewrite rule changed'); + assertTest(!isset($rules['^landing/?$']), 'page root rule was registered without an empty-path route'); 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'); } @@ -245,16 +245,33 @@ public function testActivationRegistersRewriteRulesAndFlushes(): void assertTest(WpKitTestState::$rewriteFlushes > 0, 'activation did not flush rewrite rules'); } - public function testDeactivationOnlyFlushesRewriteRules(): void + public function testDeactivationClearsPersistedRulesInsteadOfFlushing(): void { $this->makeTransport(['entries/{id}' => static function () { return 'x'; }]); + WpKitTestState::$options['rewrite_rules'] = ['^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]']; do_action('plugin_deactivate'); assertSameValue([], WpKitTestState::$rewriteRules, 'deactivation registered rewrite rules'); - assertSameValue(1, WpKitTestState::$rewriteFlushes, 'deactivation must flush exactly once'); + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'deactivation flushed, which re-saves rules still registered on this request'); + assertSameValue('', WpKitTestState::$options['rewrite_rules'], 'persisted rules were not cleared for regeneration'); + } + + public function testDeactivationAfterInitDoesNotPersistTheStillRegisteredRules(): 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'); + do_action('plugin_deactivate'); + + assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'init did not register the route rule'); + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'deactivation re-persisted the registered rules'); + assertSameValue('', WpKitTestState::$options['rewrite_rules'], 'route rule outlived deactivation'); } public function testInitFlushesOnceWhenStaticRulesAreMissingFromPersistedRules(): void @@ -307,6 +324,240 @@ public function testPersistedRewriteCheckRequiresEveryRule(): void ); } + public function testEmptyPathRouteRegistersThePageRootRule(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + + do_action('init'); + + assertSameValue('index.php?pagename=landing', WpKitTestState::$rewriteRules['^landing/?$']['query'] ?? null, 'empty-path route lost the page root rule'); + } + + public function testSubdirectoryInstallMatchesTheParsedWordPressPath(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/42'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/entries/42'; + + do_action('template_redirect'); + + assertSameValue('page-entry 42', apply_filters('the_content', 'page-'), 'subdirectory request did not match'); + } + + public function testPathInfoPermalinkMatchesTheParsedWordPressPath(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + $_SERVER['REQUEST_URI'] = '/index.php/landing/entries/7'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/entries/7'; + + do_action('template_redirect'); + + assertSameValue('page-entry 7', apply_filters('the_content', 'page-'), 'index.php permalink request did not match'); + } + + public function testWithoutAParsedPathTheHomePathIsStrippedFromTheRequestUri(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/42?preview=1'; + $GLOBALS['wp'] = new \WP(); + + do_action('template_redirect'); + + assertSameValue('page-entry 42', apply_filters('the_content', 'page-'), 'home path was not stripped from the request URI'); + } + + public function testRequestsAreDispatchedOnTemplateRedirectByDefault(): void + { + $calls = 0; + $this->makeTransport(['ping' => static function () use (&$calls) { + ++$calls; + + return 'pong'; + }]); + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/ping'; + + do_action('parse_request', $GLOBALS['wp']); + assertSameValue(0, $calls, 'default router dispatched on parse_request'); + + do_action('template_redirect'); + assertSameValue(1, $calls, 'default router did not dispatch on template_redirect'); + } + + public function testEarlyDispatchHandlesRequestsOnParseRequestOnly(): void + { + $calls = 0; + $this->makeTransport(['ping' => static function () use (&$calls) { + ++$calls; + + return 'pong'; + }])->dispatchOnParseRequest(); + $wp = new \WP(); + $wp->request = 'landing/ping'; + + do_action('parse_request', $wp); + do_action('template_redirect'); + + assertSameValue(1, $calls, 'early dispatch did not run exactly once on parse_request'); + } + + public function testInitSkipsFlushWithPlainPermalinks(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + WpKitTestState::$options['permalink_structure'] = ''; + + do_action('init'); + + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'rules were flushed although plain permalinks never use them'); + } + + public function testInitRetriesMissingRulesAtMostOncePerRetryWindow(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + + do_action('init'); + do_action('init'); + assertSameValue(1, WpKitTestState::$rewriteFlushes, 'rules that never persist were flushed on every request'); + + WpKitTestState::$currentTime = '2024-01-02 03:10:05'; + do_action('init'); + assertSameValue(2, WpKitTestState::$rewriteFlushes, 'missing rules were not retried after the window'); + } + + public function testTemplateRedirectEmptyArgumentFallsBackToTheGlobalWp(): void + { + $transport = $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/9'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/entries/9'; + + $transport->handleRequest(''); + + assertSameValue('page-entry 9', apply_filters('the_content', 'page-'), "template_redirect's '' argument hid the global WP"); + } + + public function testParsedPathWithSurroundingSlashesStillMatches(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = '/landing/entries/5/'; + + do_action('template_redirect'); + + assertSameValue('page-entry 5', apply_filters('the_content', 'page-'), 'slashes around the parsed path broke matching'); + } + + public function testRequestUriFallbackWorksWithoutAWpObject(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog/'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/3'; + + do_action('template_redirect'); + + assertSameValue('page-entry 3', apply_filters('the_content', 'page-'), 'fallback needed a WP object'); + } + + public function testHomePathIsStrippedOnlyOnASegmentBoundary(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/la'; + $_SERVER['REQUEST_URI'] = '/landing/entries/3'; + + do_action('template_redirect'); + + assertSameValue('page-entry 3', apply_filters('the_content', 'page-'), 'home path "/la" was stripped from the "/landing" segment'); + } + + public function testEarlyDispatchIgnoresUnmatchedRequests(): void + { + $calls = 0; + $this->makeTransport(['ping' => static function () use (&$calls) { + ++$calls; + + return 'pong'; + }])->dispatchOnParseRequest(); + $wp = new \WP(); + $wp->request = 'landing/other'; + + do_action('parse_request', $wp); + + assertSameValue(0, $calls, 'early dispatch ran a route for an unmatched path'); + assertTest(!isset(WpKitTestState::$filters['the_content']), 'early dispatch touched page content for an unmatched path'); + } + + public function testEarlyDispatchStillEnforcesTheRouteMethod(): void + { + $calls = 0; + new Router('static', 'landing', null); + (new RouteBase())->post('hook', static function () use (&$calls) { + ++$calls; + + return 'ok'; + }); + (new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'))->dispatchOnParseRequest(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $wp = new \WP(); + $wp->request = 'landing/hook'; + + do_action('parse_request', $wp); + + assertSameValue(0, $calls, 'early dispatch ran a POST route for GET'); + } + + public function testRetryWindowIsTrackedPerPage(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + new Router('static', 'portal', null); + (new RouteBase())->get('profile', static fn () => 'x'); + new StaticRouter('portal', 'plugin_activate', 'plugin_deactivate'); + + do_action('init'); + do_action('init'); + + assertSameValue(2, WpKitTestState::$rewriteFlushes, 'one page\'s retry window suppressed another page\'s flush'); + } + + public function testActivationFlushesEvenInsideTheRetryWindow(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + + do_action('init'); + do_action('plugin_activate'); + + assertSameValue(2, WpKitTestState::$rewriteFlushes, 'activation flush was throttled'); + } + + public function testPersistedRulesSkipFlushAfterAnEarlierRetry(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + + do_action('init'); + WpKitTestState::$options['rewrite_rules'] = ['^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]']; + WpKitTestState::$currentTime = '2024-01-02 04:00:00'; + do_action('init'); + + assertSameValue(1, WpKitTestState::$rewriteFlushes, 'persisted rules were flushed again after the window'); + } + + public function testRootRuleCoexistsWithOtherRoutes(): void + { + $this->makeTransport([ + '/' => static fn () => 'home', + 'entries/{id}' => static fn () => 'x', + ]); + + do_action('init'); + + assertTest(isset(WpKitTestState::$rewriteRules['^landing/?$']), 'root rule missing next to other routes'); + assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'route rule missing next to the root rule'); + } + private function makeTransport(array $routes = []): StaticRouter { new Router('static', 'landing', null); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0322ad7..f9482b2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -90,6 +90,14 @@ final class WpDieException extends RuntimeException { } +if (!class_exists('WP')) { + /** Minimal WP: only the parsed request path the static router reads. */ + class WP + { + public $request = ''; + } +} + if (!class_exists('WP_REST_Request')) { class WP_REST_Request { @@ -101,12 +109,28 @@ class WP_REST_Request private $route; - public function __construct($body = [], $query = [], $route = [], $json = []) + private $headers; + + private $rawBody; + + public function __construct($body = [], $query = [], $route = [], $json = [], $headers = [], $rawBody = '') { - $this->body = $body; - $this->query = $query; - $this->route = $route; - $this->json = $json; + $this->body = $body; + $this->query = $query; + $this->route = $route; + $this->json = $json; + $this->headers = $headers; + $this->rawBody = $rawBody; + } + + public function get_body() + { + return $this->rawBody; + } + + public function get_headers() + { + return $this->headers; } public function get_body_params() @@ -214,8 +238,11 @@ function resetWpKitTestState() 'time_format' => 'H:i:s', 'timezone_string' => 'UTC', 'gmt_offset' => 0, - 'rewrite_rules' => [], + 'rewrite_rules' => [], + 'permalink_structure' => '/%postname%/', + 'home' => 'https://example.test', ]; + unset($GLOBALS['wp']); WpKitTestState::$currentTime = '2024-01-02 03:04:05'; WpKitTestState::$capabilities = []; WpKitTestState::$sites = []; @@ -297,9 +324,24 @@ function wp_ob_end_flush_all() } } +/** + * Mirrors core _sanitize_text_fields(): strips tags, collapses whitespace, drops %XX octets. + */ function sanitize_text_field($value) { - return $value; + if (\is_array($value) || \is_object($value)) { + return ''; + } + + $filtered = trim(preg_replace('/[\r\n\t ]+/', ' ', strip_tags((string) $value))); + + $found = false; + while (preg_match('/%[a-f0-9]{2}/i', $filtered, $match)) { + $filtered = str_replace($match[0], '', $filtered); + $found = true; + } + + return $found ? trim(preg_replace('/ +/', ' ', $filtered)) : $filtered; } function sanitize_url($value) @@ -494,6 +536,11 @@ function add_rewrite_rule($regex, $query, $position) WpKitTestState::$rewriteRules[$regex] = compact('query', 'position'); } +function home_url($path = '') +{ + return rtrim((string) get_option('home'), '/') . '/' . ltrim($path, '/'); +} + function flush_rewrite_rules() { ++WpKitTestState::$rewriteFlushes;