diff --git a/lib/Misc/URL.php b/lib/Misc/URL.php index 6b9fcce4..da49effb 100644 --- a/lib/Misc/URL.php +++ b/lib/Misc/URL.php @@ -10,6 +10,12 @@ namespace JKingWeb\Arsse\Misc; * A collection of functions for manipulating URLs */ class URL { + + /** Returns whether a URL is absolute i.e. has a scheme */ + public static function absolute(string $url): bool { + return (bool) strlen((string) parse_url($url, \PHP_URL_SCHEME)); + } + /** Normalizes a URL * * Normalizations performed are: @@ -137,4 +143,28 @@ class URL { $out = ($absolute ? "/" : "").$out.($index ? "/" : ""); return str_replace("//", "/", $out); } + + /** Appends data to a URL's query component + * + * @param string $url The input URL + * @param string $data The data to append. This should already be escaped where necessary and not start with any delimiter + * @param string $glue The query subcomponent delimiter, usually "&". If the URL has no query, "?" will be prepended instead + */ + public static function queryAppend(string $url, string $data, string $glue = "&"): string { + if (!strlen($data)) { + return $url; + } + $insPos = strpos($url, "#"); + $insPos = $insPos === false ? strlen($url) : $insPos; + $qPos = strpos($url, "?"); + $hasQuery = $qPos !== false; + $glue = $hasQuery ? $glue : "?"; + if ($hasQuery && $insPos > 0) { + if ($url[$insPos - 1] === $glue || ($insPos - 1) == $qPos) { + // if the URL already has excess glue, use it + $glue = ""; + } + } + return substr($url, 0, $insPos).$glue.$data.substr($url, $insPos); + } } diff --git a/tests/cases/Misc/TestURL.php b/tests/cases/Misc/TestURL.php index 9d06933e..8260c0b0 100644 --- a/tests/cases/Misc/TestURL.php +++ b/tests/cases/Misc/TestURL.php @@ -75,4 +75,20 @@ class TestURL extends \JKingWeb\Arsse\Test\AbstractTest { [" ", "%20"], ]; } + + /** @dataProvider provideQueries */ + public function testAppendQueryParameters(string $url, string $query, string $exp) { + $this->assertSame($exp, URL::queryAppend($url, $query)); + } + + public function provideQueries() { + return [ + ["/", "ook=eek", "/?ook=eek"], + ["/?", "ook=eek", "/?ook=eek"], + ["/#ack", "ook=eek", "/?ook=eek#ack"], + ["/?Huh?", "ook=eek", "/?Huh?&ook=eek"], + ["/?Eh?&Huh?&", "ook=eek", "/?Eh?&Huh?&ook=eek"], + ["/#ack", "", "/#ack"], + ]; + } } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index e453151e..183e0037 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -145,33 +145,11 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $value; } - protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ServerRequest { - $url = "/fever/".$url; + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $target = "", string $user = null): ServerRequest { + $prefix = "/fever/"; + $url = $prefix.$target; $type = $type ?? "application/x-www-form-urlencoded"; - $server = [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $url, - 'HTTP_CONTENT_TYPE' => $type, - ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory", ['Content-Type' => $type]); - if (!is_array($dataGet)) { - parse_str($dataGet, $dataGet); - } - $req = $req->withRequestTarget($url)->withQueryParams($dataGet); - if (is_array($dataPost)) { - $req = $req->withParsedBody($dataPost); - } else { - parse_str($dataPost, $arr); - $req = $req->withParsedBody($arr); - } - if (isset($user)) { - if (strlen($user)) { - $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); - } else { - $req = $req->withAttribute("authenticationFailed", true); - } - } - return $req; + return $this->serverRequest($method, $url, $prefix, [], [], $dataPost, $type, $dataGet, $user); } public function setUp() { @@ -457,7 +435,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return [ 'Not an API request' => [$this->req(""), new EmptyResponse(404)], 'Wrong method' => [$this->req("api", "", "GET"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])], - 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], + 'Wrong content type' => [$this->req("api", '{"api_key":"validToken"}', "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], ]; } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index da0b951b..5ae10feb 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -298,40 +298,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ], ]; - protected function req(string $method, string $target, string $data = "", array $headers = []): ResponseInterface { - $url = "/index.php/apps/news/api/v1-2".$target; - $server = [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $url, - 'PHP_AUTH_USER' => "john.doe@example.com", - 'PHP_AUTH_PW' => "secret", - 'REMOTE_USER' => "john.doe@example.com", - ]; - if (strlen($data)) { - $server['HTTP_CONTENT_TYPE'] = "application/json"; - } - $req = new ServerRequest($server, [], $url, $method, "php://memory"); - if (Arsse::$user->auth("john.doe@example.com", "secret")) { - $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", "john.doe@example.com"); - } - foreach ($headers as $key => $value) { - if (!is_null($value)) { - $req = $req->withHeader($key, $value); - } else { - $req = $req->withoutHeader($key); - } - } - if (strlen($data)) { - $body = $req->getBody(); - $body->write($data); - $req = $req->withBody($body); - } - $q = $req->getUri()->getQuery(); - if (strlen($q)) { - parse_str($q, $q); - $req = $req->withQueryParams($q); - } - $req = $req->withRequestTarget($target); + protected function req(string $method, string $target, string $data = "", array $headers = [], bool $authenticated = true): ResponseInterface { + $prefix = "/index.php/apps/news/api/v1-2"; + $url = $prefix.$target; + $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", [], $authenticated ? "john.doe@example.com" : ""); return $this->h->dispatch($req); } @@ -340,7 +310,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { self::setConf(); // create a mock user manager Arsse::$user = \Phake::mock(User::class); - \Phake::when(Arsse::$user)->auth->thenReturn(true); Arsse::$user->id = "john.doe@example.com"; // create a mock database interface Arsse::$db = \Phake::mock(Database::class); @@ -357,9 +326,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function testSendAuthenticationChallenge() { - \Phake::when(Arsse::$user)->auth->thenReturn(false); $exp = new EmptyResponse(401); - $this->assertMessage($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/", "", [], false)); } public function testRespondToInvalidPaths() { diff --git a/tests/cases/REST/NextCloudNews/TestVersions.php b/tests/cases/REST/NextCloudNews/TestVersions.php index c803f8d6..dff02afa 100644 --- a/tests/cases/REST/NextCloudNews/TestVersions.php +++ b/tests/cases/REST/NextCloudNews/TestVersions.php @@ -19,13 +19,9 @@ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { } protected function req(string $method, string $target): ResponseInterface { - $url = "/index.php/apps/news/api".$target; - $server = [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $url, - ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory"); - $req = $req->withRequestTarget($target); + $prefix = "/index.php/apps/news/api"; + $url = $prefix.$target; + $req = $this->serverRequest($method, $url, $prefix); return (new Versions)->dispatch($req); } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index d3d5870d..7c8d230b 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -126,27 +126,10 @@ LONG_STRING; } protected function req($data, string $method = "POST", string $target = "", string $strData = null, string $user = null): ResponseInterface { - $url = "/tt-rss/api".$target; - $server = [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $url, - 'HTTP_CONTENT_TYPE' => "application/x-www-form-urlencoded", - ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory"); - $body = $req->getBody(); - if (!is_null($strData)) { - $body->write($strData); - } else { - $body->write(json_encode($data)); - } - $req = $req->withBody($body)->withRequestTarget($target); - if (isset($user)) { - if (strlen($user)) { - $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); - } else { - $req = $req->withAttribute("authenticationFailed", true); - } - } + $prefix = "/tt-rss/api"; + $url = $prefix.$target; + $body = $strData ?? json_encode($data); + $req = $this->serverRequest($method, $url, $prefix, [], ['HTTP_CONTENT_TYPE' => "application/x-www-form-urlencoded"], $body, "application/json", [], $user); return $this->h->dispatch($req); } diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php index 1d5b8a7b..dc986787 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -34,20 +34,9 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { } protected function req(string $target, string $method = "GET", string $user = null): ResponseInterface { - $url = "/tt-rss/feed-icons/".$target; - $server = [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $url, - ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory"); - $req = $req->withRequestTarget($target); - if (isset($user)) { - if (strlen($user)) { - $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); - } else { - $req = $req->withAttribute("authenticationFailed", true); - } - } + $prefix = "/tt-rss/feed-icons/"; + $url = $prefix.$target; + $req = $this->serverRequest($method, $url, $prefix, [], [], null, "", [], $user); return $this->h->dispatch($req); } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 6334e5c5..a2e66a31 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -13,10 +13,12 @@ use JKingWeb\Arsse\Db\Driver; use JKingWeb\Arsse\Db\Result; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\URL; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\XmlResponse; @@ -61,6 +63,68 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } + protected function serverRequest(string $method, string $url, string $urlPrefix, array $headers = [], array $vars = [], $body = null, string $type = "", $params = [], string $user = null): ServerRequestInterface { + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $url, + ]; + if (strlen($type)) { + $server['HTTP_CONTENT_TYPE'] = $type; + } + if (isset($params)) { + if (is_array($params)) { + $params = implode("&", array_map(function($v, $k) { + return rawurlencode($k).(isset($v) ? "=".rawurlencode($v) : ""); + }, $params, array_keys($params))); + } + $url = URL::queryAppend($url, (string) $params); + } + $q = parse_url($url, \PHP_URL_QUERY); + if (strlen($q ?? "")) { + parse_str($q, $params); + } else { + $params = []; + } + $parsedBody = null; + if (isset($body)) { + if (is_string($body) && in_array(strtolower($type), ["", "application/x-www-form-urlencoded"])) { + parse_str($body, $parsedBody); + } elseif (!is_string($body) && in_array(strtolower($type), ["application/json", "text/json"])) { + $body = json_encode($body, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } elseif (!is_string($body) && in_array(strtolower($type), ["", "application/x-www-form-urlencoded"])) { + $parsedBody = $body; + $body = http_build_query($body, "a", "&"); + } + } + $server = array_merge($server, $vars); + $req = new ServerRequest($server, [], $url, $method, "php://memory", [], [], $params, $parsedBody); + if (isset($user)) { + if (strlen($user)) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } + } + if (strlen($type) &&strlen($body ?? "")) { + $req = $req->withHeader("Content-Type", $type); + } + foreach ($headers as $key => $value) { + if (!is_null($value)) { + $req = $req->withHeader($key, $value); + } else { + $req = $req->withoutHeader($key); + } + } + $target = substr(URL::normalize($url), strlen($urlPrefix)); + $req = $req->withRequestTarget($target); + if (strlen($body ?? "")) { + $p = $req->getBody(); + $p->write($body); + $req = $req->withBody($p); + } + return $req; + } + public function assertException($msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { if ($msg instanceof \JKingWeb\Arsse\AbstractException) {